feat: Add Settings and Subjects screens with theme reveal functionality

- Implemented SettingsScreen for user preferences including theme selection, reminders, and notifications.
- Created SubjectsScreen to display subjects with upcoming lessons and associated details.
- Introduced ThemeReveal for a smooth transition between light and dark themes.
- Added support for managing notes and tasks related to subjects.
- Enhanced UI components for better user experience and accessibility.
This commit is contained in:
2026-10-24 02:47:37 +03:00
parent 416527fb33
commit 297091cb0e
54 changed files with 182588 additions and 172428 deletions

View File

@@ -158,6 +158,8 @@ npm run sync
- Строка с предметом и аудиторией над строками по видам — заголовок: её аудитория переходит к занятиям под ней.
- **Совмещённый вид.** «(л./п.з.)» и «(л. и п.з.)» — вид «Лекция и практика».
- **Подгруппа в тексте.** «12н.2п/гр. Экономика…» — занятие одной подгруппы.
- **Разрядка** (парсер 19): «Ф и з и ч е с к а я к у л ь т у р а», «З а н я т и я в П о л о ц к е» — буквы через пробел, слова через несколько. Склеивается до сжатия пробелов: «Физическая культура», «Занятия в Полоцке» (теперь это и заметка о городе). Инициалы и короткие слова не трогаются.
- **«со 2 по 14 нед.»** читается как «2-14 нед.»; пробелы внутри кавычек («« Майнор »») убираются.
- **Вид занятия после преподавателя** (парсер 18): «Русский язык как иностранный Козыренко А.Н.(п.з.) ауд.100» — название кончается там, где начинается преподаватель или вид, смотря что раньше. Раньше фамилия попадала в название (25‑ЭПи, 25‑ЭМ).
- **Ячейка из одной точки** — не занятие.
- **Время в начале ячейки:**
@@ -170,6 +172,7 @@ npm run sync
**Занятия в другом городе** (`server/places.js`, парсер 14). Большинство пар — в Новополоцке, но часть факультетов учится в Полоцке, и Word отмечает это по‑разному:
- **«Занятия в Полоцке» над первой парой дня** (24‑ИЯ (РА)и‑1…6, 25‑ИЯ (РА)и‑3, 24‑РГФи‑1, 22‑ЛОМК, 25‑ТДи) — не занятие, а заметка дня для столбца: в этот день все пары группы или подгруппы идут в том городе (и аудитории у них полоцкие — Г, Д, Е, Ж). Заметка лежит в разобранном документе в `places` (`{day, date, groups, subgroups, place, text}`), занятиям этого дня проставляется `place`. Если в той же ячейке под заметкой есть пара, это обычное занятие без приставки в названии. Занятие всей группы получает город, когда заметка стоит во всех её подгруппах.
- **Город строкой в ячейке занятия** — «Новополоцк», «г.Новополоцк» — или **после аудитории** — «246(Новополоцк)», «121(г.Новополоцк)» — город этого занятия; из названия и аудитории он убирается.
- **Заметка посреди дня** (25‑ИТ, пятница: «Занятия в Полоцке» в строке 13:40) действует с этой пары: в разобранном документе у неё `from`, утренние пары города не получают. `/api/timetable` отдаёт `from` в `places`, Android пишет «С 13:40 — занятия в Полоцке».
- Пишется и «Занятия в г. Новополоцк», «Занятия проходят в …», «Занятия в Междуречье». Город приводится к именительному падежу («Полоцке» → «Полоцк»). Название предмета вроде «История Полоцка» городом не считается.
- В документах «Введение в высшее образование» строка «г. Полоцк, Дом офицеров» даёт занятию город, а место проведения остаётся в тексте.
- Какие заметки показать над днём группы, решает общий модуль `shared/places.js`: им пользуются и `/api/timetable`, и PWA. Одинаковая заметка во всех подгруппах сводится к одной для всей группы, а заметка своей подгруппы показывается как заметка всего дня.
@@ -249,7 +252,7 @@ DOMAIN=schedule.example.by docker compose --profile https up -d --build
В папке `android/` — нативный клиент на Kotlin и Jetpack Compose с компонентами Material 3 Expressive (`MaterialExpressiveTheme`, `ModalWideNavigationRail` и `WideNavigationRail`, соединённые `ToggleButton`, `LoadingIndicator`). Базовая тема синяя (тональная схема M3); с «Цветами обоев» — динамическая, а цвета предметов и зелёной недели гармонизируются с ней. Разделы открываются из бокового меню: на телефоне оно выезжает, на планшете в портрете и на телефоне в альбомной ориентации — узкая боковая панель, на планшете в альбомной ориентации меню открыто постоянно. На широком экране разделы делятся на две панели, как в School Planner: «Обзор» — карточки и недельная сетка, «Планы» и «Календарь» — список и выбранный день, «Поиск» — результаты и день преподавателя или аудитории; «Предметы» — сеткой карточек, «Расписание» открывается неделей; карточки лежат на подложке с оттенком, системные панели следуют теме приложения.
**Складные телефоны.** Приложение следит за сгибом экрана через Jetpack WindowManager (`FoldingFeature`, `ui/Fold.kt`).
**Складные телефоны.** Приложение следит за сгибом экрана через Jetpack WindowManager (`FoldingFeature`, `ui/components/Fold.kt`).
- **Фолды** (Galaxy Z Fold, Pixel Fold) в разложенном виде — окно через сгиб с короткой стороной от 600dp — получают планшетный макет:
- «Обзор», «Планы», «Календарь» и «Поиск» — две панели, как две страницы, по обе стороны вертикального сгиба, и в плоском, и в полураскрытом виде;
- «Расписание» открывается неделей на карточке;
@@ -267,9 +270,14 @@ DOMAIN=schedule.example.by docker compose --profile https up -d --build
- **Сегодня — на текущей паре.** Страница сегодняшнего дня открывается на идущей паре (или следующей), а не на первой утренней.
- **Виброотклик:** тик при выборе дня в ленте и при листании дней пальцем, при выборе в кнопках-переключателях. Подтверждение при выполнении задания и отметке посещения, щелчок у переключателей настроек.
- **Скелетоны вместо спиннера:** пока грузится первое расписание группы, «Обзор», «Расписание» и «Предметы» показывают серые заготовки в форме своих карточек с мягким бликом. Так же выглядят занятия преподавателя или аудитории в поиске и каталог групп. Для TalkBack это одно «Загрузка». При системной настройке «Убрать анимацию» блик, появление карточек и прочие украшения отключаются.
- **Карточка вырастает в подробности** (container transform). Пара открывается из своей карточки: из «Обзора», расписания по дням, недельной сетки, календаря «Планов» и подставки «ноутбука». Рамка плавно растягивается до полного экрана (на планшете — до большой панели по центру), картинка карточки растворяется, подробности проявляются. «Назад», предиктивный жест и касание за панелью сворачивают её обратно в карточку. Так же «+» в «Планах» и календаре раскрывается в лист нового задания; лист можно стянуть вниз за ручку.
- **Карточка вырастает в подробности** (container transform). Пара открывается из своей карточки: из «Обзора», расписания по дням, недельной сетки, календаря «Планов» и подставки «ноутбука». Рамка плавно растягивается до полного экрана (на планшете — до большой панели по центру), а содержимое едет вместе с ней, как в M3 (container transform из Material Components): и карточка, и страница подробностей масштабируются по ширине рамки от её левого верхнего угла, так что заголовок и сведения о паре выходят из самой карточки. Страница проявляется поверх карточки за первую четверть пути, углы выпрямляются за три четверти; при закрытии она гаснет поздно, между 60 и 90 % пути, и рамка с карточкой внутри сжимается на место. «Назад», предиктивный жест и касание за панелью сворачивают её обратно в карточку. Так же «+» в «Планах» и календаре раскрывается в лист нового задания; лист можно стянуть вниз за ручку, и он тоже сжимается обратно в «+». Контейнер рисует только содержимое источника (иконку «+», текст карточки), а заливку, углы и тень — свои: у «+» тень плавно уходит при росте и возвращается к его уровню при посадке, так что ни второй формы внутри, ни скачка тени в конце нет.
- **Живая текущая пара.** Вокруг идущей пары по часовой стрелке от левого верхнего угла рисуется рамка: какая часть пары прошла. Она подрастает плавно, без скачков. Цифры обратного отсчёта («ещё 44 мин», «Через 15 мин») прокручиваются, как на табло. Линия «сейчас» в недельной сетке ползёт вниз вместе со временем.
- **Лента дней.** Выбранный день — один круг под всей лентой: при выборе другого дня он перетекает туда на пружинах, передний край спешит, задний догоняет, и по дороге круг вытягивается в каплю. В «Расписании» точка в круге дня значит «есть пары». Когда пролистываешь на другую неделю, её точки появляются по очереди, с понедельника.
- **Долгое нажатие на пару** (в расписании, недельной сетке и «Обзоре») открывает лист действий: «Подробнее» (страница вырастает из той же карточки), «Задание или заметка», «Был / Пропустил», когда пара уже началась, «Поделиться» (предмет, вид, день, время, аудитория, преподаватель) и «Скрыть такие занятия» с «Вернуть». Для TalkBack — действие «Действия» у карточки.
- **Между парами** в расписании дня — строка, когда есть что сказать: «Окно 1 ч 35 мин» (от 45 минут, то есть пропущена пара), «Другой корпус: 211А → 254В · перерыв 30 мин» (корпус — буквы после номера аудитории), «Переезд в Полоцк» по городу пары; переезд с перерывом меньше часа выделен красным. Обычные перерывы 10–30 минут и дистанционные пары строки не дают.
- **Избранное и недавние в поиске.** Звёздочка у преподавателя или аудитории добавляет их в «Избранное»; до ввода запроса поиск показывает «Избранное» и «Недавние» (последние восемь открытых). Касание открывает сегодняшний день, и у идущей пары — «Идёт · ещё 40 мин» с рамкой прогресса, у ближайшей — «Через 15 мин»: где преподаватель сейчас, видно сразу.
- **Свайпы по заданиям** (как в Buckwheat). Вправо — «Выполнено», у выполненного — «Не выполнено». Влево — «Перенести»: задание переезжает к следующей паре того же предмета, общий план — на завтра; внизу «Перенесено на пн, 26 октября, 15:20 · Вернуть». Под строкой открывается прямоугольная полоса с иконкой действия; за порогом цвет действия заливает её кругом от иконки, иконка подпрыгивает, телефон щёлкает. Отпущенная строка всегда пружинит на место. Пока студент ни разу не свайпнул задание, одна строка на экране сама покачивается в стороны и показывает действия под собой; касание её останавливает, первый свайп убирает подсказку насовсем. На «Доске» свайпов нет: там жест листает дни. Для TalkBack те же действия — в меню действий строки.
- **Смена темы.** «Светлая», «Тёмная» и «Цвета обоев» в настройках меняют тему кругом, который расходится от нажатой кнопки: снимок старого экрана лежит поверх приложения, и в нём растёт дыра с новой темой (`ui/theme/ThemeReveal.kt`). Значки строки состояния рисует система, они меняются сразу.
- **Выполненное задание.** При отметке текст зачёркивается слева направо, строка за строкой, и задание чуть задерживается на месте, прежде чем уйти к выполненным. Когда закрыто последнее открытое, у «Всё сделано» выскакивает галочка.
- **Обновление жестом.** Потянуть вниз в «Обзоре» и в расписании: экспрессивный индикатор M3 с меняющейся формой и щелчок отклика, когда тянуть уже достаточно.
- **Свежие изменения на виду.** Если университет перезалил документ и пары изменились или добавились, их карточки в расписании дважды вспыхивают цветом (один раз, при первом показе).
@@ -286,7 +294,7 @@ DOMAIN=schedule.example.by docker compose --profile https up -d --build
- **Группа, которой больше нет.** Если сервер отвечает `group_not_found` или `document_not_found` — группу разделили («24‑ИЯ(РА)» стала «24‑ИЯ (РА)и‑1»…), или документ ушёл из каталога, — плашка с текстом сервера предлагает «Выбрать группу». Сохранённая копия остаётся на экране, а устаревшая группа убирается из недавних.
- **Изменения в расписании.** Как в PWA: когда университет перезаливает документ группы, приложение сравнивает новое расписание с копией на устройстве. В «Обзоре» появляется карточка «Расписание обновлено · N изменений» с кнопкой «Показать», а в списке видно, что изменилось, что добавлено и что удалено, например «Аудитория: 374 → 375». Карточка показывается до просмотра или закрытия, последний список остаётся в настройках («Последние изменения»). Сравнение срабатывает и при фоновом обновлении (`SyncWorker`, раз в 6 часов). Если изменения нашлись в фоне, приходит уведомление «Расписание 23-АРХ изменилось» с первыми изменениями, например «Проектирование общественных зданий: аудитория 374 → 375». Уведомление локальное, сервер push для него не нужен. Его можно выключить в настройках («Изменения в расписании», по умолчанию включено), а на Android 13+ нужно разрешение на уведомления. Считается только новая версия того же файла: смена версии парсера или переход на другой документ изменениями не считаются. Сравниваются строки Word по дням недели и разовые занятия со своей датой, а не каждая рассчитанная дата.
- Задания и заметки к паре: из карточки занятия — к этой паре или к следующей паре того же предмета. Задания отмечаются выполненными, просроченные подсвечиваются; список — в «Обзоре», по предмету — в «Предметах», точка на блоке в недельной сетке. Хранятся только на устройстве (`notes.json`) и привязаны к группе.
- **Фото к заданиям:** в редакторе задания или заметки — «Снять» (приложение камеры) и «Галерея» (системный выбор фото, без доступа к памяти), до 6 фото. Фото уменьшается до 2048 px по длинной стороне, поворачивается по EXIF и хранится в `files/photos/` рядом с заметками. Удаление фото, задания или отмена правки убирает файлы, а при запуске удаляются фото, на которые не ссылается ни одна заметка. В списках у задания миниатюра (с «+N»), на карточке пары — значок фото. Просмотр — на весь экран, с листанием, масштабом щипком и двойным касанием. Задание может быть и одним фото, без текста.
- **Фото к заданиям:** в редакторе задания или заметки — «Снять» (приложение камеры) и «Галерея» (системный выбор фото, без доступа к памяти), до 6 фото. Фото уменьшается до 2048 px по длинной стороне, поворачивается по EXIF и хранится в `files/photos/` рядом с заметками. Удаление фото, задания или отмена правки убирает файлы, а при запуске удаляются фото, на которые не ссылается ни одна заметка. В списках у задания миниатюра (с «+N»), на карточке пары — значок фото. Просмотр — на весь экран, с листанием, масштабом щипком и двойным касанием. Он открывается по M3 как container transform для «героического» момента: фото вырастает из миниатюры (в строке задания или в редакторе) из квадратной обрезки в целый снимок, углы выпрямляются, вокруг поднимается чёрный фон, а при закрытии (крестик, «Назад», предиктивный жест) фото сжимается обратно в свою миниатюру; у фото без своей миниатюры — в ту, из которой открыли, и на последнем отрезке перетекает в её снимок. Миниатюра запоминает место на экране, поэтому переход работает и из диалога редактора. Задание может быть и одним фото, без текста.
- Посещаемость: «был» или «пропустил» в карточке занятия и одним касанием в «Обзоре» для прошедших сегодня пар; статистика по каждому предмету в «Предметах». Хранится на устройстве (`attendance.json`).
- Скрытие занятий: «Скрыть такие занятия» в карточке занятия убирает предмет с этим типом занятия из расписания, обзора, виджетов и напоминаний; вернуть можно в настройках.
- Второй виджет «Следующая пара» (4×1) и плитка в шторке быстрых настроек с текущей или следующей парой и аудиторией.
@@ -365,6 +373,11 @@ SIMCTL_CHILD_PSU_API_URL=http://localhost:3010/ xcrun simctl launch booted org.x
- `server/index.js` — API и выдача собранной PWA.
- `server/limits.js` — ограничения импорта: число одновременных разборов и запросов с одного адреса.
- `server/errors.js` — коды ошибок API и их HTTP-статусы.
- `android/app/src/main/kotlin/org/xtimms/psu/schedule/` — приложение Android:
- `data/` — API, модели, настройки, задания и посещаемость; `notify/`, `widget/`, `work/`, `tile/` — уведомления, виджеты, фоновая синхронизация, плитка;
- `ui/` — `MainViewModel` и меню; `ui/components/` — общие элементы (заголовки, лента дней, скелетоны, отмена, свайпы, сгиб экрана), `ui/motion/` — переходы и container transform, `ui/theme/` — тема и её смена;
- `ui/lesson/` — карточка и страница пары, слоты дня, цвета предметов, отметки сессии, города, посещаемость; `ui/notes/` — задания, заметки и фото;
- экраны: `ui/home/` (обзор, изменения, сессия), `ui/schedule/` (дни, неделя, подставка), `ui/plans/` (планы, календарь), `ui/subjects/`, `ui/groups/`, `ui/search/`, `ui/settings/`, `ui/onboarding/`.
- `ios/Shared/` — модели, даты в часовом поясе Минска, расчёт пар и общее хранилище; общие для приложения и виджетов.
- `ios/PSUSchedule/` — приложение iOS: `AppModel.swift` (состояние и загрузка), `API.swift`, `Reminders.swift`, экраны в `Views/`.
- `ios/Widgets/` — виджеты WidgetKit.

View File

@@ -19,8 +19,8 @@ android {
applicationId = "org.xtimms.psu.schedule"
minSdk = 26
targetSdk = 37
versionCode = 1
versionName = "0.1.0"
versionCode = 2
versionName = "0.1.1"
buildConfigField("String", "API_URL", "\"${apiUrl.get()}\"")
}

File diff suppressed because it is too large Load Diff

View File

@@ -7,10 +7,10 @@ import androidx.compose.foundation.gestures.Orientation
import kotlin.coroutines.cancellation.CancellationException
import androidx.compose.runtime.LaunchedEffect
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import org.xtimms.psu.schedule.ui.UndoArea
import org.xtimms.psu.schedule.ui.LocalMorph
import org.xtimms.psu.schedule.ui.MorphHost
import org.xtimms.psu.schedule.ui.MorphOverlay
import org.xtimms.psu.schedule.ui.components.UndoArea
import org.xtimms.psu.schedule.ui.motion.LocalMorph
import org.xtimms.psu.schedule.ui.motion.MorphHost
import org.xtimms.psu.schedule.ui.motion.MorphOverlay
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.rememberTransition
import androidx.compose.animation.core.SeekableTransitionState
@@ -20,8 +20,8 @@ import androidx.compose.material.icons.outlined.CalendarViewWeek
import androidx.compose.material.icons.outlined.Checklist
import androidx.compose.material.icons.rounded.CalendarViewWeek
import androidx.compose.material.icons.rounded.Checklist
import org.xtimms.psu.schedule.ui.CalendarScreen
import org.xtimms.psu.schedule.ui.PlansScreen
import org.xtimms.psu.schedule.ui.plans.CalendarScreen
import org.xtimms.psu.schedule.ui.plans.PlansScreen
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.rememberScrollState
@@ -39,13 +39,13 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.height
import androidx.compose.material.icons.automirrored.rounded.MenuOpen
import org.xtimms.psu.schedule.ui.materialFadeThrough
import org.xtimms.psu.schedule.ui.Emphasized
import org.xtimms.psu.schedule.ui.MotionDuration
import org.xtimms.psu.schedule.ui.motion.materialFadeThrough
import org.xtimms.psu.schedule.ui.motion.Emphasized
import org.xtimms.psu.schedule.ui.motion.MotionDuration
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.animation.core.tween
import org.xtimms.psu.schedule.ui.materialSharedAxisX
import org.xtimms.psu.schedule.ui.rememberSlideDistance
import org.xtimms.psu.schedule.ui.motion.materialSharedAxisX
import org.xtimms.psu.schedule.ui.motion.rememberSlideDistance
import android.graphics.Color
import android.os.Bundle
import androidx.activity.ComponentActivity
@@ -60,10 +60,10 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import org.xtimms.psu.schedule.ui.LocalHinge
import org.xtimms.psu.schedule.ui.EmphasizedDecelerate
import org.xtimms.psu.schedule.ui.EmphasizedAccelerate
import org.xtimms.psu.schedule.ui.LocalFoldTablet
import org.xtimms.psu.schedule.ui.components.LocalHinge
import org.xtimms.psu.schedule.ui.motion.EmphasizedDecelerate
import org.xtimms.psu.schedule.ui.motion.EmphasizedAccelerate
import org.xtimms.psu.schedule.ui.components.LocalFoldTablet
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row
@@ -115,27 +115,31 @@ import kotlinx.coroutines.launch
import org.xtimms.psu.schedule.data.ThemeMode
import org.xtimms.psu.schedule.data.today
import org.xtimms.psu.schedule.ui.AppNav
import org.xtimms.psu.schedule.ui.GroupsScreen
import org.xtimms.psu.schedule.ui.HomeScreen
import org.xtimms.psu.schedule.ui.Onboarding
import org.xtimms.psu.schedule.ui.LocalDisplay
import org.xtimms.psu.schedule.ui.LocalMarks
import org.xtimms.psu.schedule.ui.groups.GroupsScreen
import org.xtimms.psu.schedule.ui.home.HomeScreen
import org.xtimms.psu.schedule.ui.onboarding.Onboarding
import org.xtimms.psu.schedule.ui.lesson.LocalDisplay
import org.xtimms.psu.schedule.ui.lesson.LocalMarks
import org.xtimms.psu.schedule.ui.LocalMenuProgress
import org.xtimms.psu.schedule.ui.theme.card
import androidx.compose.foundation.layout.widthIn
import org.xtimms.psu.schedule.ui.LocalNotes
import org.xtimms.psu.schedule.ui.LocalSubjectSlots
import org.xtimms.psu.schedule.ui.notes.LocalNotes
import org.xtimms.psu.schedule.ui.lesson.LocalSubjectSlots
import org.xtimms.psu.schedule.data.Display
import org.xtimms.psu.schedule.ui.MainViewModel
import org.xtimms.psu.schedule.ui.ScheduleScreen
import org.xtimms.psu.schedule.ui.SearchScreen
import org.xtimms.psu.schedule.ui.SettingsScreen
import org.xtimms.psu.schedule.ui.SubjectsScreen
import org.xtimms.psu.schedule.ui.subjectSlots
import org.xtimms.psu.schedule.ui.schedule.ScheduleScreen
import org.xtimms.psu.schedule.ui.search.SearchScreen
import org.xtimms.psu.schedule.ui.settings.SettingsScreen
import org.xtimms.psu.schedule.ui.subjects.SubjectsScreen
import org.xtimms.psu.schedule.ui.lesson.subjectSlots
import org.xtimms.psu.schedule.ui.theme.ScheduleTheme
import org.xtimms.psu.schedule.ui.theme.ThemeRevealHost
import org.xtimms.psu.schedule.ui.lesson.LessonActionsHost
import org.xtimms.psu.schedule.ui.theme.isDark
import org.xtimms.psu.schedule.ui.theme.page
import java.time.LocalDate
import org.xtimms.psu.schedule.ui.components.LocalSwipeHint
import org.xtimms.psu.schedule.ui.components.SwipeHint
private enum class Tab(val label: String, val icon: ImageVector, val selectedIcon: ImageVector) {
Home("Обзор", Icons.Outlined.Home, Icons.Rounded.Home),
@@ -193,9 +197,11 @@ class MainActivity : ComponentActivity() {
onDispose {}
}
// The fold of a foldable phone, for screens that split their panes around it.
val hinge = org.xtimms.psu.schedule.ui.rememberHinge(this)
val hinge = org.xtimms.psu.schedule.ui.components.rememberHinge(this)
ScheduleTheme(mode = s?.theme ?: ThemeMode.System, dynamicColor = s?.dynamicColor ?: false) {
androidx.compose.runtime.CompositionLocalProvider(org.xtimms.psu.schedule.ui.LocalHinge provides hinge) {
// Light and dark swap with a circle spreading from the finger (ThemeReveal.kt).
ThemeRevealHost(key = dark to (s?.dynamicColor ?: false)) {
androidx.compose.runtime.CompositionLocalProvider(org.xtimms.psu.schedule.ui.components.LocalHinge provides hinge) {
Surface(color = MaterialTheme.colorScheme.page, modifier = Modifier.fillMaxSize()) {
// Wait for the stored settings so a returning student never sees the onboarding.
if (s != null) {
@@ -207,7 +213,14 @@ class MainActivity : ComponentActivity() {
label = "onboarding",
) { first ->
if (first) Onboarding(vm, onDone = { onboarding = false })
else App(vm, firstRun = s.current == null, launch = launch.value, onLaunched = { launch.value = null })
else {
// Until the first swipe, a task row shows how it is done (SwipeActions.kt).
val hint = remember(s.swipeTried) { SwipeHint(!s.swipeTried, vm::swipeTried) }
androidx.compose.runtime.CompositionLocalProvider(LocalSwipeHint provides hint) {
App(vm, firstRun = s.current == null, launch = launch.value, onLaunched = { launch.value = null })
}
}
}
}
}
}
@@ -281,6 +294,8 @@ private fun App(vm: MainViewModel, firstRun: Boolean, launch: Launch?, onLaunche
val morph = remember { MorphHost() }
UndoArea(Modifier.fillMaxSize()) {
androidx.compose.runtime.CompositionLocalProvider(LocalMorph provides morph) {
// A long press on a lesson anywhere opens its actions (LessonActions.kt).
LessonActionsHost(vm) {
BoxWithConstraints(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.card)) {
val wide = maxWidth >= MEDIUM_WIDTH
// An open book-style foldable gets the tablet layout; a flip phone keeps the phone one.
@@ -535,6 +550,7 @@ private fun App(vm: MainViewModel, firstRun: Boolean, launch: Launch?, onLaunche
}
}
}
}
// A card's details and the "+" sheet open over everything, growing from what was tapped.
MorphOverlay(morph)
}

View File

@@ -60,7 +60,16 @@ data class WeeklyDay(val day: Int, val lessons: List<Lesson> = emptyList(), val
* when `subgroup` is set) are in another town (server/timetable.js placesOn).
*/
@Serializable
data class DayPlace(val place: String, val text: String, val subgroup: String? = null)
data class DayPlace(
val place: String,
val text: String,
val subgroup: String? = null,
/** "13:40": the note stands in the middle of the day and holds from that lesson on. */
val from: String? = null,
) {
/** "С 13:40 — занятия в Полоцке", or the note as it is when it holds all day. */
val label: String get() = from?.let { "С $it — " + text.replaceFirstChar { c -> c.lowercase() } } ?: text
}
/** The notes for the student's subgroup; a note for their own subgroup reads as the whole day's. */
fun List<DayPlace>.forSubgroup(subgroup: String?): List<DayPlace> =

View File

@@ -56,6 +56,12 @@ data class UserSettings(
val roomTime: Int = 7 * 60,
/** The lesson under way as an ongoing notification (a Live Update on Android 16). */
val liveLesson: Boolean = false,
/** The student has swiped a task once: the rows stop showing how. */
val swipeTried: Boolean = false,
/** Teachers and rooms starred in the search, as "teacher:Ковшик О.С." or "room:206Г". */
val searchFavorites: List<String> = emptyList(),
/** The teachers and rooms opened in the search lately, newest first. */
val searchRecent: List<String> = emptyList(),
val recent: List<SavedGroup> = emptyList(),
val display: Display = Display(),
/**
@@ -90,6 +96,9 @@ class Settings(private val context: Context) {
val roomAlerts = booleanPreferencesKey("room_alerts")
val roomTime = intPreferencesKey("room_time")
val liveLesson = booleanPreferencesKey("live_lesson")
val swipeTried = booleanPreferencesKey("swipe_tried")
val searchFavorites = stringPreferencesKey("search_favorites")
val searchRecent = stringPreferencesKey("search_recent")
val recent = stringPreferencesKey("recent")
val colors = booleanPreferencesKey("show_colors")
val teachers = booleanPreferencesKey("show_teachers")
@@ -103,6 +112,9 @@ class Settings(private val context: Context) {
val plansDone = booleanPreferencesKey("plans_done")
}
private fun Preferences.list(key: Preferences.Key<String>): List<String> =
this[key]?.let { runCatching { json.decodeFromString<List<String>>(it) }.getOrNull() }.orEmpty()
private fun Preferences.choiceMap(): Map<String, Map<String, String>> =
this[Keys.choices]?.let { runCatching { json.decodeFromString<Map<String, Map<String, String>>>(it) }.getOrNull() }.orEmpty()
@@ -143,6 +155,9 @@ class Settings(private val context: Context) {
roomAlerts = this[Keys.roomAlerts] ?: true,
roomTime = this[Keys.roomTime] ?: 7 * 60,
liveLesson = this[Keys.liveLesson] ?: false,
swipeTried = this[Keys.swipeTried] ?: false,
searchFavorites = list(Keys.searchFavorites),
searchRecent = list(Keys.searchRecent),
recent = this[Keys.recent]?.let {
runCatching { json.decodeFromString<List<SavedGroup>>(it) }.getOrNull()
}.orEmpty(),
@@ -229,6 +244,18 @@ class Settings(private val context: Context) {
suspend fun setRoomAlerts(on: Boolean) = context.store.edit { it[Keys.roomAlerts] = on }
suspend fun setRoomTime(minutes: Int) = context.store.edit { it[Keys.roomTime] = minutes }
suspend fun setLiveLesson(on: Boolean) = context.store.edit { it[Keys.liveLesson] = on }
suspend fun swipeTried() = context.store.edit { it[Keys.swipeTried] = true }
/** Stars a teacher or a room of the search, or takes the star back. */
suspend fun toggleSearchFavorite(target: String) = context.store.edit { prefs ->
val list = prefs.list(Keys.searchFavorites)
prefs[Keys.searchFavorites] = json.encodeToString(if (target in list) list - target else list + target)
}
/** Remembers a teacher or a room opened in the search; the latest eight stay. */
suspend fun rememberSearch(target: String) = context.store.edit { prefs ->
prefs[Keys.searchRecent] = json.encodeToString((listOf(target) + prefs.list(Keys.searchRecent).filter { it != target }).take(8))
}
/** Hides or shows again a recurring lesson of the open group. */
suspend fun setHidden(key: String, hidden: Boolean) = context.store.edit { prefs ->

View File

@@ -13,8 +13,8 @@ import org.xtimms.psu.schedule.data.ScheduleChanges
import org.xtimms.psu.schedule.data.dayName
import org.xtimms.psu.schedule.data.longLabel
import org.xtimms.psu.schedule.data.parseDate
import org.xtimms.psu.schedule.ui.changeDate
import org.xtimms.psu.schedule.ui.changesLabel
import org.xtimms.psu.schedule.ui.home.changeDate
import org.xtimms.psu.schedule.ui.home.changesLabel
/**
* A notification when the background sync finds that the university republished the group's

View File

@@ -74,7 +74,8 @@ object DayAlerts {
val at = date.minusDays(1).atTime(LocalTime.ofSecondOfDay(settings.travelTime * 60L)).atZone(MINSK).toInstant()
if (at.isAfter(now)) {
val note = places.first()
val title = "Завтра " + note.text.replaceFirstChar { it.lowercase() } + (note.subgroup?.let { " · ${it.lowercase()}" } ?: "")
// "Завтра с 13:40 — занятия в Полоцке" when the town is only for the afternoon.
val title = "Завтра " + note.label.replaceFirstChar { it.lowercase() } + (note.subgroup?.let { " · ${it.lowercase()}" } ?: "")
val text = "Первая пара в ${first.start} — ${first.subject}" +
(withPlace(rooms(first), first.place)?.let { ", $it" } ?: "") +
". Всего ${lessons.distinctBy { it.start }.size} ${plural(lessons.distinctBy { it.start }.size, "пара", "пары", "пар")}."

View File

@@ -24,7 +24,7 @@ import org.xtimms.psu.schedule.data.nowDateTime
import org.xtimms.psu.schedule.data.parseTime
import org.xtimms.psu.schedule.data.today
import org.xtimms.psu.schedule.data.withPlace
import org.xtimms.psu.schedule.ui.lessonEnd
import org.xtimms.psu.schedule.ui.lesson.lessonEnd
import java.time.Duration
import java.time.LocalDate
import java.time.LocalDateTime

View File

@@ -23,6 +23,11 @@ import org.xtimms.psu.schedule.data.ThemeMode
import org.xtimms.psu.schedule.data.Timetable
import org.xtimms.psu.schedule.data.UserSettings
import org.xtimms.psu.schedule.work.SyncWorker
import org.xtimms.psu.schedule.ui.components.UndoHost
import org.xtimms.psu.schedule.data.parseDate
import org.xtimms.psu.schedule.ui.notes.dueLabel
import org.xtimms.psu.schedule.ui.notes.general
import org.xtimms.psu.schedule.ui.notes.nextOccurrence
data class Status(val loading: Boolean = false, val message: String? = null, val offline: Boolean = false, val code: String? = null) {
/**
@@ -89,6 +94,26 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
}
fun toggleNote(id: String) = viewModelScope.launch { app.notes.toggle(id) }
/**
* Moves a task or a note to the next lesson of its subject, a plan without one to the next
* day, and offers the old place back in [undo]. With no such lesson loaded it says so.
*/
fun postponeNote(note: Note, undo: UndoHost?) = viewModelScope.launch {
val from = parseDate(note.date)
val moved = if (note.general) note.copy(date = from.plusDays(1).toString())
else {
val t = timetable.value ?: return@launch
val next = nextOccurrence(t, note.subject, from, note.start, settings.value?.subgroup)
?: return@launch undo?.say("Следующей пары «${note.subject}» в расписании нет") ?: Unit
note.copy(date = next.first.toString(), start = next.second.start)
}
app.notes.save(moved)
// "на пн, 26 октября, 15:20": the time too, as the next lesson may be later the same day.
val day = moved.dueLabel().removePrefix("к ")
val at = if (moved.start.isNotBlank() && moved.start !in day) "$day, ${moved.start}" else day
undo?.offer("Перенесено на $at", onUndo = { viewModelScope.launch { app.notes.save(note) } })
}
/** The key new notes are filed under, or null before a group is chosen. */
fun currentGroupKey(): String? = settings.value?.current?.let(::groupKey)
@@ -170,6 +195,11 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
SyncWorker.afterChange(getApplication())
}
fun toggleSearchFavorite(target: String) = viewModelScope.launch { app.settings.toggleSearchFavorite(target) }
fun rememberSearch(target: String) = viewModelScope.launch { app.settings.rememberSearch(target) }
fun swipeTried() = viewModelScope.launch { if (settings.value?.swipeTried == false) app.settings.swipeTried() }
fun setTheme(mode: ThemeMode) = viewModelScope.launch { app.settings.setTheme(mode) }
fun setDynamicColor(on: Boolean) = viewModelScope.launch { app.settings.setDynamicColor(on) }
fun setDisplay(display: Display) = viewModelScope.launch { app.settings.setDisplay(display) }

View File

@@ -1,274 +0,0 @@
package org.xtimms.psu.schedule.ui
import android.graphics.BitmapFactory
import android.net.Uri
import android.util.LruCache
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.calculatePan
import androidx.compose.foundation.gestures.calculateZoom
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.PhotoCamera
import androidx.compose.material.icons.rounded.PhotoLibrary
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChanged
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.core.content.FileProvider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.xtimms.psu.schedule.app
import java.io.File
// Photos of the board or the assignment, pinned to a task or a note (NotesStore keeps the files).
/** Decoded thumbnails, so lists scrolling past the same photos do not decode them again. */
private val thumbnails = object : LruCache<String, ImageBitmap>(24 * 1024 * 1024) {
override fun sizeOf(key: String, value: ImageBitmap) = value.width * value.height * 4
}
/** A photo decoded no larger than [max] pixels on its long side; null while it loads. */
@Composable
private fun rememberPhoto(file: File, max: Int): ImageBitmap? {
val key = "${file.name}@$max"
return produceState(thumbnails.get(key), key) {
if (value != null) return@produceState
value = withContext(Dispatchers.IO) {
runCatching {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(file.path, bounds)
var sample = 1
while (maxOf(bounds.outWidth, bounds.outHeight) / (sample * 2) >= max) sample *= 2
BitmapFactory.decodeFile(file.path, BitmapFactory.Options().apply { inSampleSize = sample })?.asImageBitmap()
}.getOrNull()
}?.also { thumbnails.put(key, it) }
}.value
}
/** A square thumbnail; [onClick] opens the viewer. */
@Composable
fun PhotoThumb(name: String, size: Dp, onClick: (() -> Unit)?, modifier: Modifier = Modifier) {
val store = LocalContext.current.app.notes
val px = with(LocalDensity.current) { size.roundToPx() } * 2
val image = rememberPhoto(store.photo(name), px)
Box(
modifier
.size(size)
.clip(MaterialTheme.shapes.medium)
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
.then(if (onClick != null) Modifier.clickable(onClickLabel = "Открыть фото", onClick = onClick) else Modifier),
) {
image?.let { Image(it, contentDescription = "Фото", contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize()) }
}
}
/**
* The editor's photos: thumbnails with a remove button, then "Снять" (the camera app) and
* "Галерея" (the system photo picker, no storage permission needed).
*/
@Composable
fun NotePhotosEditor(photos: List<String>, onAdd: (String) -> Unit, onRemove: (String) -> Unit) {
val context = LocalContext.current
val store = context.app.notes
val scope = rememberCoroutineScope()
var loading by remember { mutableIntStateOf(0) }
var viewing by remember { mutableStateOf<Int?>(null) }
fun import(uris: List<Uri>) {
uris.forEach { uri ->
loading++
scope.launch {
store.importPhoto(uri)?.let(onAdd)
loading--
}
}
}
// The camera app writes the full-size shot here; the store keeps a smaller copy.
val shot = remember { File(context.cacheDir, "camera/shot.jpg") }
val camera = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { taken ->
if (taken) {
loading++
scope.launch {
store.importPhoto(Uri.fromFile(shot))?.let(onAdd)
shot.delete()
loading--
}
}
}
val gallery = rememberLauncherForActivityResult(ActivityResultContracts.PickMultipleVisualMedia(MAX_PHOTOS)) { import(it) }
val room = MAX_PHOTOS - photos.size
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
if (photos.isNotEmpty() || loading > 0) Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.horizontalScroll(rememberScrollState()),
) {
photos.forEachIndexed { index, name ->
Box {
PhotoThumb(name, 72.dp, onClick = { viewing = index })
FilledIconButton(
onClick = { onRemove(name) },
colors = IconButtonDefaults.filledIconButtonColors(
containerColor = MaterialTheme.colorScheme.inverseSurface,
contentColor = MaterialTheme.colorScheme.inverseOnSurface,
),
modifier = Modifier.align(Alignment.TopEnd).padding(4.dp).size(24.dp),
) { Icon(Icons.Rounded.Close, contentDescription = "Убрать фото", modifier = Modifier.size(16.dp)) }
}
}
repeat(loading) {
Box(
Modifier.size(72.dp).clip(MaterialTheme.shapes.medium).background(MaterialTheme.colorScheme.surfaceContainerHighest),
contentAlignment = Alignment.Center,
) { CircularProgressIndicator(Modifier.size(24.dp), strokeWidth = 2.dp) }
}
}
if (room > 0) Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
OutlinedButton(
onClick = {
shot.parentFile?.mkdirs()
val uri = FileProvider.getUriForFile(context, context.packageName + ".photos", shot)
// A phone without a camera app: the gallery is still there.
runCatching { camera.launch(uri) }
},
modifier = Modifier.weight(1f),
) {
Icon(Icons.Rounded.PhotoCamera, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Снять", maxLines = 1)
}
OutlinedButton(
onClick = { gallery.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) },
modifier = Modifier.weight(1f),
) {
Icon(Icons.Rounded.PhotoLibrary, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Галерея", maxLines = 1)
}
}
}
viewing?.let { PhotoViewer(photos, it) { viewing = null } }
}
/** Photos full screen, swiped sideways; pinch or double-tap to zoom into the board. */
@Composable
fun PhotoViewer(photos: List<String>, start: Int, onDismiss: () -> Unit) {
if (photos.isEmpty()) return
val store = LocalContext.current.app.notes
Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) {
val pager = rememberPagerState(initialPage = start.coerceIn(0, photos.lastIndex)) { photos.size }
Box(Modifier.fillMaxSize().background(Color.Black)) {
HorizontalPager(state = pager, modifier = Modifier.fillMaxSize()) { page ->
var scale by remember { mutableFloatStateOf(1f) }
var offset by remember { mutableStateOf(Offset.Zero) }
val image = rememberPhoto(store.photo(photos[page]), 2048)
Box(
Modifier
.fillMaxSize()
.pointerInput(Unit) {
detectTapGestures(onDoubleTap = {
if (scale > 1f) { scale = 1f; offset = Offset.Zero } else scale = 2.5f
})
}
.pointerInput(Unit) {
// Two fingers zoom; one finger pans only while zoomed in, so at 1x the
// pager still takes the swipe to the next photo.
awaitEachGesture {
awaitFirstDown(requireUnconsumed = false)
do {
val event = awaitPointerEvent()
if (event.changes.size > 1 || scale > 1f) {
scale = (scale * event.calculateZoom()).coerceIn(1f, 5f)
offset = if (scale > 1f) offset + event.calculatePan() else Offset.Zero
event.changes.forEach { if (it.positionChanged()) it.consume() }
}
} while (event.changes.any { it.pressed })
}
},
contentAlignment = Alignment.Center,
) {
if (image == null) CircularProgressIndicator(color = Color.White)
else Image(
image,
contentDescription = "Фото ${page + 1} из ${photos.size}",
contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxSize().graphicsLayer {
scaleX = scale
scaleY = scale
translationX = offset.x
translationY = offset.y
},
)
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().statusBarsPadding().padding(8.dp),
) {
IconButton(onClick = onDismiss) { Icon(Icons.Rounded.Close, contentDescription = "Закрыть", tint = Color.White) }
Spacer(Modifier.weight(1f))
if (photos.size > 1) Surface(shape = CircleShape, color = Color.Black.copy(alpha = 0.5f), contentColor = Color.White) {
Text("${pager.currentPage + 1} / ${photos.size}", style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp))
}
}
}
}
}
/** Up to this many photos per task or note. */
const val MAX_PHOTOS = 6

View File

@@ -1,6 +1,6 @@
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
package org.xtimms.psu.schedule.ui
package org.xtimms.psu.schedule.ui.components
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
@@ -116,6 +116,13 @@ import org.xtimms.psu.schedule.data.today
import org.xtimms.psu.schedule.data.weekSpan
import org.xtimms.psu.schedule.ui.theme.WeekColors
import java.time.LocalDate
import org.xtimms.psu.schedule.ui.lesson.SubjectColor
import org.xtimms.psu.schedule.ui.lesson.luminance
import org.xtimms.psu.schedule.ui.motion.rememberReducedMotion
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.BoxWithConstraints
/** A tablet, by its smallest side: its app bars stay calm, as School Planner's do. */
@Composable
@@ -176,7 +183,11 @@ fun Heading(
Text(text, style = style, modifier = modifier.semantics { heading() })
}
/** M3 Expressive connected button group: one choice out of a few. */
/**
* M3 Expressive connected button group: one choice out of a few. The chosen one gets a check
* before its label when both fit; a long label ("Светлая" in a third of a phone) keeps whole
* and the fill alone marks it.
*/
@Composable
fun <T> ConnectedChoice(
options: List<Pair<T, String>>,
@@ -187,12 +198,20 @@ fun <T> ConnectedChoice(
mark: Boolean = true,
) {
val haptic = LocalHapticFeedback.current
val measurer = rememberTextMeasurer()
val style = MaterialTheme.typography.labelLarge
val density = LocalDensity.current
BoxWithConstraints(modifier) {
val gap = ButtonGroupDefaults.ConnectedSpaceBetween
val segment = (maxWidth - gap * (options.size - 1)) / options.size
Row(
modifier.selectableGroup(),
horizontalArrangement = Arrangement.spacedBy(ButtonGroupDefaults.ConnectedSpaceBetween),
Modifier.fillMaxWidth().selectableGroup(),
horizontalArrangement = Arrangement.spacedBy(gap),
) {
options.forEachIndexed { index, (value, label) ->
val checked = value == selected
val text = with(density) { measurer.measure(label, style, maxLines = 1).size.width.toDp() }
val check = checked && mark && text + CHECK + PADDING * 2 <= segment
ToggleButton(
checked = checked,
onCheckedChange = {
@@ -212,17 +231,23 @@ fun <T> ConnectedChoice(
options.lastIndex -> ButtonGroupDefaults.connectedTrailingButtonShapes()
else -> ButtonGroupDefaults.connectedMiddleButtonShapes()
},
contentPadding = PaddingValues(horizontal = PADDING),
) {
if (checked && mark) {
if (check) {
Icon(Icons.Rounded.Check, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(6.dp))
}
Text(label, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(label, style = style, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
}
}
/** A connected button's side padding, and its check with the gap after it. */
private val PADDING = 12.dp
private val CHECK = 24.dp
/** M3 search input field on a tonal container; reports every edit through [onQueryChange]. */
@Composable
fun SearchField(query: String, onQueryChange: (String) -> Unit, placeholder: String, modifier: Modifier = Modifier) {

View File

@@ -1,4 +1,4 @@
package org.xtimms.psu.schedule.ui
package org.xtimms.psu.schedule.ui.components
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Animatable
@@ -33,6 +33,8 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.xtimms.psu.schedule.ui.motion.EmphasizedDecelerate
import org.xtimms.psu.schedule.ui.motion.rememberReducedMotion
// Finishing a task: its text is struck through from left to right, line after line, and once the
// last open task of the plans is done a small check pops up by "Всё сделано".

View File

@@ -1,4 +1,4 @@
package org.xtimms.psu.schedule.ui
package org.xtimms.psu.schedule.ui.components
import android.app.Activity
import androidx.compose.foundation.layout.BoxWithConstraints

View File

@@ -0,0 +1,140 @@
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
package org.xtimms.psu.schedule.ui.components
import androidx.compose.ui.unit.Dp
import androidx.compose.foundation.layout.heightIn
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialShapes
import androidx.compose.material3.toShape
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.CloudOff
import androidx.compose.material.icons.rounded.ErrorOutline
import androidx.compose.material.icons.rounded.EventBusy
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun Banner(
text: String,
offline: Boolean,
onClose: (() -> Unit)?,
onRetry: (() -> Unit)? = null,
/** A way out the message asks for, such as "Выбрать группу". */
action: Pair<String, () -> Unit>? = null,
) {
Surface(
shape = MaterialTheme.shapes.large,
color = if (offline) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.errorContainer,
contentColor = if (offline) MaterialTheme.colorScheme.onSecondaryContainer else MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
) {
val buttons: @Composable () -> Unit = {
if (onRetry != null) TextButton(onClick = onRetry) { Text("Повторить") }
action?.let { (label, onClick) -> TextButton(onClick = onClick) { Text(label) } }
if (onClose != null) TextButton(onClick = onClose) { Text("OK") }
}
// With an action the message needs the whole width; the buttons go under it.
if (action != null) Column(Modifier.padding(start = 16.dp, end = 4.dp, top = 12.dp, bottom = 4.dp)) {
Row {
Icon(Icons.Rounded.ErrorOutline.takeIf { !offline } ?: Icons.Rounded.CloudOff, contentDescription = null)
Spacer(Modifier.width(12.dp))
Text(text, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f).padding(end = 12.dp))
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { buttons() }
} else Row(Modifier.padding(start = 16.dp, end = 4.dp, top = 8.dp, bottom = 8.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(if (offline) Icons.Rounded.CloudOff else Icons.Rounded.ErrorOutline, contentDescription = null)
Spacer(Modifier.width(12.dp))
Text(text, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f).padding(vertical = 8.dp))
buttons()
}
}
}
/**
* Centres an empty state in the space it is given — a phone screen or a tablet's pane —
* and scrolls when that space is too low, so the illustration is never squeezed.
*/
@Composable
fun CenteredEmpty(
modifier: Modifier = Modifier,
/** Room kept clear inside the scroll (under a FAB): content scrolls past it, never gets cut. */
bottomInset: Dp = 0.dp,
content: @Composable () -> Unit,
) {
BoxWithConstraints(modifier.fillMaxSize()) {
val height = maxHeight
Column(
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).heightIn(min = height).padding(bottom = bottomInset),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) { content() }
}
}
/**
* Empty state in the RideBus style: an icon on a seven-sided cookie shape, a title,
* a calm explanation and an optional tonal action.
*/
@Composable
fun Empty(
title: String,
text: String,
icon: ImageVector = Icons.Rounded.EventBusy,
action: Pair<String, () -> Unit>? = null,
) {
Column(
Modifier.fillMaxWidth().padding(vertical = 48.dp, horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(112.dp)
.background(MaterialTheme.colorScheme.primaryContainer, MaterialShapes.Cookie7Sided.toShape()),
) {
Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.onPrimaryContainer, modifier = Modifier.size(48.dp))
}
Spacer(Modifier.height(20.dp))
Text(title, style = MaterialTheme.typography.titleLargeEmphasized, textAlign = TextAlign.Center)
Spacer(Modifier.height(6.dp))
Text(
text,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
action?.let { (label, onClick) ->
Spacer(Modifier.height(20.dp))
FilledTonalButton(onClick = onClick) { Text(label) }
}
}
}

View File

@@ -1,4 +1,4 @@
package org.xtimms.psu.schedule.ui
package org.xtimms.psu.schedule.ui.components
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloat
@@ -37,6 +37,7 @@ import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import org.xtimms.psu.schedule.ui.theme.card
import org.xtimms.psu.schedule.ui.motion.rememberReducedMotion
// Placeholders in the shape of what is loading: the first timetable of a group, a teacher's day,
// the catalog. The screen keeps its layout instead of a lone spinner in the middle.

View File

@@ -0,0 +1,239 @@
package org.xtimms.psu.schedule.ui.components
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animate
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.offset
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.graphics.drawscope.scale
import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.semantics.CustomAccessibilityAction
import androidx.compose.ui.semantics.customActions
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.xtimms.psu.schedule.ui.motion.rememberReducedMotion
import kotlin.math.abs
import kotlin.math.hypot
import kotlin.math.roundToInt
// Swiping a row sideways does something with it, as in Buckwheat's history: a strip opens under
// the row with the action's icon; past the threshold the action's colour floods it in a circle
// from the icon, the icon bounces and the phone ticks. Letting go there runs the action, and the
// row always springs back into place.
/** One side's action: its icon, its name for TalkBack, its colours and what it does. */
class SwipeAction(
val icon: ImageVector,
val label: String,
val color: Color,
val onColor: Color,
val run: () -> Unit,
)
/**
* The swipe hint: until the student has swiped a row once, one row on the screen rocks to each
* side by itself, showing the actions under it. [tried] remembers that they found it.
*/
@Stable
class SwipeHint(val show: Boolean, val tried: () -> Unit) {
internal var owner by mutableStateOf<Any?>(null)
}
val LocalSwipeHint = staticCompositionLocalOf<SwipeHint?> { null }
/**
* A row with [start] (swiped to the right) and [end] (to the left) actions. Either may be null;
* with both null it is just [content]. TalkBack users get the same actions in the actions menu.
*/
@Composable
fun SwipeActions(
start: SwipeAction?,
end: SwipeAction?,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
if (start == null && end == null) return Box(modifier) { content() }
// The row's shift: set straight from the finger, so a late drag event can never stop the
// spring that brings it back.
var x by remember { mutableFloatStateOf(0f) }
var width by remember { mutableFloatStateOf(0f) }
val scope = rememberCoroutineScope()
var settling by remember { mutableStateOf<Job?>(null) }
val haptic = LocalHapticFeedback.current
val reduced = rememberReducedMotion()
val startNow by rememberUpdatedState(start)
val endNow by rememberUpdatedState(end)
// A third of the row, but never a hair-trigger on a narrow one.
val threshold = maxOf(width * 0.33f, with(LocalDensity.current) { 88.dp.toPx() })
val armed = when {
x > threshold -> start
x < -threshold -> end
else -> null
}
fun settle() {
settling?.cancel()
settling = scope.launch {
animate(x, 0f, animationSpec = spring(dampingRatio = 0.75f, stiffness = Spring.StiffnessMediumLow)) { v, _ -> x = v }
}
}
// The hint, on the first row that asks for it while the student has never swiped.
val hint = LocalSwipeHint.current
val me = remember { Any() }
var touched by remember { mutableStateOf(false) }
LaunchedEffect(hint) { if (hint?.show == true && hint.owner == null) hint.owner = me }
DisposableEffect(hint) { onDispose { if (hint?.owner == me) hint.owner = null } }
val hinting = hint?.show == true && hint.owner === me && !touched && !reduced && width > 0f
LaunchedEffect(hinting) {
if (!hinting) return@LaunchedEffect
// Half way to the threshold and back, to one side and then the other, as in Buckwheat.
var side = if (start != null) 1f else -1f
try {
while (true) {
delay(1000)
animate(0f, side * threshold / 2, animationSpec = tween(500, easing = FastOutSlowInEasing)) { v, _ -> x = v }
animate(x, 0f, animationSpec = tween(500, easing = FastOutSlowInEasing)) { v, _ -> x = v }
if ((side > 0 && endNow != null) || (side < 0 && startNow != null)) side = -side
}
} finally {
if (x != 0f) settle()
}
}
// The flood from the icon and the icon's bounce, when the threshold is passed.
val flood = remember { Animatable(0f) }
val bounce = remember { Animatable(1f) }
var wasArmed by remember { mutableStateOf(false) }
LaunchedEffect(armed != null) {
val on = armed != null
if (on == wasArmed) return@LaunchedEffect
wasArmed = on
haptic.performHapticFeedback(if (on) HapticFeedbackType.GestureThresholdActivate else HapticFeedbackType.SegmentFrequentTick)
if (reduced) {
flood.snapTo(if (on) 1f else 0f)
return@LaunchedEffect
}
if (on) {
launch { flood.animateTo(1f, tween(350)) }
bounce.snapTo(0.8f)
bounce.animateTo(1.25f, spring(dampingRatio = Spring.DampingRatioHighBouncy, stiffness = Spring.StiffnessMedium))
bounce.animateTo(1f, spring(dampingRatio = Spring.DampingRatioLowBouncy))
} else {
launch { bounce.animateTo(1f) }
flood.animateTo(0f, tween(200))
}
}
val rest = MaterialTheme.colorScheme.surfaceContainerHighest
val restInk = MaterialTheme.colorScheme.onSurfaceVariant
val startIcon = start?.let { rememberVectorPainter(it.icon) }
val endIcon = end?.let { rememberVectorPainter(it.icon) }
val drag = rememberDraggableState { delta ->
val max = if (startNow != null) width else 0f
val min = if (endNow != null) -width else 0f
x = (x + delta).coerceIn(min, max)
}
Box(
modifier
.clipToBounds()
.onSizeChanged { width = it.width.toFloat() }
.semantics {
customActions = listOfNotNull(
start?.let { CustomAccessibilityAction(it.label) { it.run(); true } },
end?.let { CustomAccessibilityAction(it.label) { it.run(); true } },
)
}
// A finger on the row stops the hint there.
.pointerInput(Unit) {
awaitEachGesture {
awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
touched = true
}
}
.drawBehind {
if (x == 0f) return@drawBehind
val action = (if (x > 0) start else end) ?: return@drawBehind
val painter = (if (x > 0) startIcon else endIcon) ?: return@drawBehind
// Only the strip the row has uncovered, square at its ends.
val left = if (x > 0) 0f else size.width + x
val right = if (x > 0) x else size.width
clipRect(left, 0f, right, size.height) {
drawRect(rest)
val icon = 24.dp.toPx()
val margin = 24.dp.toPx()
val center = Offset(if (x > 0) margin + icon / 2 else size.width - margin - icon / 2, size.height / 2)
if (flood.value > 0f) drawCircle(action.color, radius = hypot(size.width, size.height) * flood.value, center = center)
val ink = lerp(restInk, action.onColor, flood.value)
// The icon fades in as the strip opens.
val shown = (abs(x) / (margin * 2 + icon)).coerceIn(0f, 1f)
translate(center.x - icon / 2, center.y - icon / 2) {
scale(bounce.value, pivot = Offset(icon / 2, icon / 2)) {
with(painter) { draw(Size(icon, icon), alpha = shown, colorFilter = ColorFilter.tint(ink)) }
}
}
}
}
.draggable(
drag,
Orientation.Horizontal,
onDragStarted = {
settling?.cancel()
hint?.tried()
},
onDragStopped = {
val action = when {
x > threshold -> startNow
x < -threshold -> endNow
else -> null
}
settle()
action?.run?.invoke()
},
),
) {
Box(Modifier.offset { IntOffset(x.roundToInt(), 0) }) { content() }
}
}

View File

@@ -1,4 +1,4 @@
package org.xtimms.psu.schedule.ui
package org.xtimms.psu.schedule.ui.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
@@ -42,6 +42,14 @@ class UndoHost(val state: SnackbarHostState, private val scope: CoroutineScope)
}
}
}
/** A message with nothing to take back: "У предмета больше нет пар в расписании". */
fun say(message: String) {
scope.launch {
state.currentSnackbarData?.dismiss()
state.showSnackbar(message, duration = SnackbarDuration.Short)
}
}
}
val LocalUndo = staticCompositionLocalOf<UndoHost?> { null }

View File

@@ -0,0 +1,13 @@
package org.xtimms.psu.schedule.ui.groups
private val FACULTIES = mapOf(
"Финансово-экономический факультет" to "ФЭФ",
"Факультет информационных технологий" to "ФИТ",
"Факультет компьютерных наук и электроники" to "ФКНЭ",
"Гуманитарный факультет" to "ГФ",
"Инженерно-строительный факультет" to "ИСФ",
"Механико-технологический факультет" to "МТФ",
"Юридический факультет" to "ЮФ",
)
fun facultyShort(name: String): String? = name.takeIf { it.isNotEmpty() }?.let { FACULTIES[it] ?: it }

View File

@@ -1,6 +1,6 @@
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
package org.xtimms.psu.schedule.ui
package org.xtimms.psu.schedule.ui.groups
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.foundation.layout.height
@@ -57,6 +57,22 @@ import org.xtimms.psu.schedule.data.attempt
import org.xtimms.psu.schedule.data.GroupsResponse
import org.xtimms.psu.schedule.data.SavedGroup
import org.xtimms.psu.schedule.data.choiceKey
import org.xtimms.psu.schedule.ui.MainViewModel
import org.xtimms.psu.schedule.ui.components.Banner
import org.xtimms.psu.schedule.ui.components.CardRow
import org.xtimms.psu.schedule.ui.components.ConnectedChoice
import org.xtimms.psu.schedule.ui.components.ContentColumn
import org.xtimms.psu.schedule.ui.components.Empty
import org.xtimms.psu.schedule.ui.components.Heading
import org.xtimms.psu.schedule.ui.components.ListCard
import org.xtimms.psu.schedule.ui.components.RowDivider
import org.xtimms.psu.schedule.ui.components.RowsSkeleton
import org.xtimms.psu.schedule.ui.components.SearchField
import org.xtimms.psu.schedule.ui.components.appBarColors
import org.xtimms.psu.schedule.ui.components.avatarLabel
import org.xtimms.psu.schedule.ui.components.navBarPadding
import org.xtimms.psu.schedule.ui.lesson.subjectColor
import org.xtimms.psu.schedule.ui.notes.open
private val PLAIN_PERIOD = Regex("^(осенний|весенний) семестр$|^расписание из word$", RegexOption.IGNORE_CASE)

View File

@@ -1,4 +1,4 @@
package org.xtimms.psu.schedule.ui
package org.xtimms.psu.schedule.ui.home
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -41,6 +41,7 @@ import org.xtimms.psu.schedule.ui.theme.card
import java.time.Instant
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import org.xtimms.psu.schedule.ui.components.Heading
/** "3 изменения". */
fun changesLabel(n: Int) = "$n ${plural(n, "изменение", "изменения", "изменений")}"

View File

@@ -1,12 +1,11 @@
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
package org.xtimms.psu.schedule.ui
package org.xtimms.psu.schedule.ui.home
import androidx.compose.animation.AnimatedContent
import androidx.compose.material3.IconButton
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowLeft
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.ui.draw.clip
import androidx.compose.ui.semantics.clearAndSetSemantics
@@ -70,7 +69,6 @@ import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -84,15 +82,12 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.delay
import org.xtimms.psu.schedule.data.DayPlace
import org.xtimms.psu.schedule.data.Lesson
import org.xtimms.psu.schedule.data.Timetable
import org.xtimms.psu.schedule.data.longLabel
import org.xtimms.psu.schedule.data.minutesLabel
import org.xtimms.psu.schedule.data.monday
import org.xtimms.psu.schedule.data.nowTime
import org.xtimms.psu.schedule.data.parseTime
import org.xtimms.psu.schedule.data.plural
import org.xtimms.psu.schedule.data.today
import org.xtimms.psu.schedule.data.weekSpan
@@ -101,28 +96,49 @@ import org.xtimms.psu.schedule.data.withPlace
import java.time.Duration
import java.time.LocalDate
import java.time.LocalTime
/** One time slot of a day: parallel lessons of subgroups share it. */
data class Slot(val date: LocalDate, val start: LocalTime, val end: LocalTime, val lessons: List<Lesson>)
fun MainViewModel.slotsOn(t: Timetable, date: LocalDate, subgroup: String?): List<Slot> =
repository.lessonsOn(t, date, subgroup, hidden)
.groupBy { it.start }
.map { (start, lessons) -> Slot(date, parseTime(start), lessonEnd(lessons.first(), t), lessons) }
.sortedBy { it.start }
/** The current time, refreshed every half minute while the screen is shown. */
@Composable
fun rememberClock(): LocalTime {
var clock by remember { mutableStateOf(nowTime()) }
LaunchedEffect(Unit) {
while (true) {
delay(30_000)
clock = nowTime()
}
}
return clock
}
import org.xtimms.psu.schedule.ui.AppNav
import org.xtimms.psu.schedule.ui.MainViewModel
import org.xtimms.psu.schedule.ui.MenuButton
import org.xtimms.psu.schedule.ui.components.Banner
import org.xtimms.psu.schedule.ui.components.CenteredEmpty
import org.xtimms.psu.schedule.ui.components.ContentColumn
import org.xtimms.psu.schedule.ui.components.DetailPane
import org.xtimms.psu.schedule.ui.components.Empty
import org.xtimms.psu.schedule.ui.components.Heading
import org.xtimms.psu.schedule.ui.components.ListCard
import org.xtimms.psu.schedule.ui.components.LocalUndo
import org.xtimms.psu.schedule.ui.components.OverviewSkeleton
import org.xtimms.psu.schedule.ui.components.PaneRow
import org.xtimms.psu.schedule.ui.components.TimeText
import org.xtimms.psu.schedule.ui.components.WeekMark
import org.xtimms.psu.schedule.ui.components.appBarColors
import org.xtimms.psu.schedule.ui.components.navBarPadding
import org.xtimms.psu.schedule.ui.components.twoPane
import org.xtimms.psu.schedule.ui.lesson.AttendanceCard
import org.xtimms.psu.schedule.ui.lesson.DayPlaces
import org.xtimms.psu.schedule.ui.lesson.LocalDisplay
import org.xtimms.psu.schedule.ui.lesson.SessionBadge
import org.xtimms.psu.schedule.ui.lesson.SessionDot
import org.xtimms.psu.schedule.ui.lesson.Slot
import org.xtimms.psu.schedule.ui.lesson.SubjectDot
import org.xtimms.psu.schedule.ui.lesson.placesOn
import org.xtimms.psu.schedule.ui.lesson.rememberClock
import org.xtimms.psu.schedule.ui.lesson.rememberLessonOpener
import org.xtimms.psu.schedule.ui.lesson.session
import org.xtimms.psu.schedule.ui.lesson.sessionColors
import org.xtimms.psu.schedule.ui.lesson.slotsOn
import org.xtimms.psu.schedule.ui.motion.AnimatedItem
import org.xtimms.psu.schedule.ui.motion.Entrance
import org.xtimms.psu.schedule.ui.motion.ExpressiveRefresh
import org.xtimms.psu.schedule.ui.motion.RollingText
import org.xtimms.psu.schedule.ui.motion.materialSharedAxisX
import org.xtimms.psu.schedule.ui.motion.morphOrigin
import org.xtimms.psu.schedule.ui.motion.rememberSlideDistance
import org.xtimms.psu.schedule.ui.notes.LocalNotes
import org.xtimms.psu.schedule.ui.notes.NoteEditor
import org.xtimms.psu.schedule.ui.notes.NoteRow
import org.xtimms.psu.schedule.ui.schedule.WeekGrid
import org.xtimms.psu.schedule.ui.lesson.lessonPress
@Composable
fun HomeScreen(vm: MainViewModel, nav: AppNav) {
@@ -331,7 +347,7 @@ private fun TasksCard(vm: MainViewModel, onEdit: (Note) -> Unit, onAll: () -> Un
},
) {
(if (expanded) pending else pending.take(4)).forEach { note ->
NoteRow(note, onToggle = { vm.toggleNote(note.id) }, onClick = { onEdit(note) }, showSubject = true)
NoteRow(note, onToggle = { vm.toggleNote(note.id) }, onClick = { onEdit(note) }, showSubject = true, onPostpone = { vm.postponeNote(note, it) })
}
Row(Modifier.padding(start = 12.dp)) {
if (pending.size > 4) TextButton(onClick = { expanded = !expanded }) {
@@ -505,11 +521,6 @@ private fun DayButton(onClick: () -> Unit) {
}
}
@Composable
fun SubjectDot(subject: String, size: Dp = 12.dp, modifier: Modifier = Modifier) {
Box(modifier.size(size).background(subjectColor(subject).container, CircleShape))
}
private fun Slot.timeLabel() = "$start → $end"
/** The next lesson in full: subject, time with a countdown, room and teacher. */
@@ -521,7 +532,7 @@ private fun FeaturedLesson(lesson: Lesson, slot: Slot, clock: LocalTime, onClick
.fillMaxWidth()
.morphOrigin("featured|${slot.date}|${lesson.key}|${lesson.start}", MaterialTheme.colorScheme.card, 12.dp)
.clip(MaterialTheme.shapes.medium)
.clickable(onClickLabel = "Подробнее", role = Role.Button, onClick = onClick),
.lessonPress(lesson, slot.date, onClick),
) {
SubjectDot(lesson.subject, 14.dp, Modifier.padding(top = 8.dp))
Spacer(Modifier.width(16.dp))
@@ -563,7 +574,7 @@ fun CompactLesson(lesson: Lesson, slot: Slot, onClick: (() -> Unit)?) {
if (onClick != null) Modifier
.morphOrigin("compact|${slot.date}|${lesson.key}|${lesson.start}", MaterialTheme.colorScheme.card, 12.dp)
.clip(MaterialTheme.shapes.medium)
.clickable(onClickLabel = "Подробнее", role = Role.Button, onClick = onClick)
.lessonPress(lesson, slot.date, onClick)
else Modifier,
),
verticalAlignment = Alignment.CenterVertically,

View File

@@ -1,6 +1,6 @@
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
package org.xtimms.psu.schedule.ui
package org.xtimms.psu.schedule.ui.home
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
@@ -42,6 +42,8 @@ import org.xtimms.psu.schedule.data.weekdayName
import org.xtimms.psu.schedule.ui.theme.card
import java.time.LocalDate
import java.time.temporal.ChronoUnit
import org.xtimms.psu.schedule.ui.MainViewModel
import org.xtimms.psu.schedule.ui.components.Heading
/** Upcoming exams of the open group, soonest first; the cache of another group is ignored. */
@Composable

View File

@@ -1,6 +1,6 @@
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
package org.xtimms.psu.schedule.ui
package org.xtimms.psu.schedule.ui.lesson
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
@@ -34,6 +34,12 @@ import org.xtimms.psu.schedule.data.Mark
import org.xtimms.psu.schedule.data.nowTime
import org.xtimms.psu.schedule.data.today
import java.time.LocalDate
import org.xtimms.psu.schedule.ui.MainViewModel
import org.xtimms.psu.schedule.ui.components.ConnectedChoice
import org.xtimms.psu.schedule.ui.components.Heading
import org.xtimms.psu.schedule.ui.components.ListCard
import org.xtimms.psu.schedule.ui.components.RowDivider
import org.xtimms.psu.schedule.ui.components.TimeText
/** Attendance marks of the open group, for cards deep in the tree. */
val LocalMarks = staticCompositionLocalOf<List<Mark>> { emptyList() }

View File

@@ -0,0 +1,101 @@
package org.xtimms.psu.schedule.ui.lesson
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.DirectionsWalk
import androidx.compose.material.icons.rounded.Coffee
import androidx.compose.material.icons.rounded.DirectionsBus
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.unit.dp
import org.xtimms.psu.schedule.data.Lesson
import org.xtimms.psu.schedule.data.minutesLabel
import java.time.Duration
// Between two lessons of a day: a free window where a pair is missing, a walk to another
// building, a ride to another town. Breaks of 10–30 minutes are the usual and say nothing.
/** What lies between two lessons: the gap, and where the next one is, when it is elsewhere. */
class Between(val minutes: Long, val window: Boolean, val building: Pair<String, String>?, val town: String?, val away: String?)
/** From this many minutes the gap is a window: a pair is missing, not a break. */
private const val WINDOW = 45L
/** The building of a lesson's room, its letters: "Г" of "305Г", "" for "121"; null when unknown. */
private fun Lesson.building(): String? =
if (distance) null else rooms.firstOrNull()?.uppercase()?.trim()?.takeLastWhile { it.isLetter() }
/** "из Полоцка", "из Междуречья". */
private fun fromTown(town: String): String = "из " + when {
town.endsWith("е") -> town.dropLast(1) + "я"
town.endsWith("к") -> town + "а"
else -> town
}
/**
* Between [a] and [b], two slots in a row of one day; null when nothing is worth a line. Only
* a slot with one lesson has a room to leave from or go to.
*/
fun between(a: Slot, b: Slot): Between? {
val minutes = Duration.between(a.end, b.start).toMinutes()
if (minutes < 0) return null
val from = a.lessons.singleOrNull()
val to = b.lessons.singleOrNull()
val window = minutes >= WINDOW
val moved = from != null && to != null && from.place != to.place
val town = if (moved) to?.place else null
val away = if (moved && to?.place == null) from?.place else null
val building = if (!moved && from != null && to != null) {
val x = from.building()
val y = to.building()
if (x != null && y != null && x != y) from.rooms.first() to to.rooms.first() else null
} else null
if (!window && !moved && building == null) return null
return Between(minutes, window, building, town, away)
}
/** The line between two lesson cards: "Окно 1 ч 35 мин · Другой корпус: 305Г → 208А". */
@Composable
fun BetweenRow(b: Between, modifier: Modifier = Modifier) {
val parts = listOfNotNull(
if (b.window) "Окно ${minutesLabel(b.minutes)}" else null,
b.town?.let { "Переезд в $it" } ?: b.away?.let { "Переезд " + fromTown(it) },
b.building?.let { (x, y) -> "Другой корпус: $x → $y" },
)
// A ride takes long: with little time it is the one thing to see.
val ride = b.town != null || b.away != null
val tight = ride && b.minutes < 60
val ink = if (tight) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant
val text = parts.joinToString(" · ") + if (!b.window) " · перерыв ${minutesLabel(b.minutes)}" else ""
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 20.dp)
.clearAndSetSemantics { contentDescription = text },
) {
Icon(
when {
ride -> Icons.Rounded.DirectionsBus
b.building != null -> Icons.AutoMirrored.Rounded.DirectionsWalk
else -> Icons.Rounded.Coffee
},
contentDescription = null,
tint = ink,
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(10.dp))
Text(text, style = MaterialTheme.typography.bodyMedium, color = ink)
}
}

View File

@@ -0,0 +1,205 @@
@file:OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
package org.xtimms.psu.schedule.ui.lesson
import android.content.Intent
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.OpenInNew
import androidx.compose.material.icons.rounded.AddTask
import androidx.compose.material.icons.rounded.Share
import androidx.compose.material.icons.rounded.VisibilityOff
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.launch
import org.xtimms.psu.schedule.data.Lesson
import org.xtimms.psu.schedule.data.hideKey
import org.xtimms.psu.schedule.data.longLabel
import org.xtimms.psu.schedule.data.shortWeekday
import org.xtimms.psu.schedule.data.withPlace
import org.xtimms.psu.schedule.ui.MainViewModel
import org.xtimms.psu.schedule.ui.components.ConnectedChoice
import org.xtimms.psu.schedule.ui.components.LocalUndo
import org.xtimms.psu.schedule.ui.motion.LocalMorph
import org.xtimms.psu.schedule.ui.motion.MorphKind
import org.xtimms.psu.schedule.ui.notes.NoteEditor
import org.xtimms.psu.schedule.ui.notes.NoteTarget
import org.xtimms.psu.schedule.ui.notes.nextOccurrence
import java.time.LocalDate
// A long press on a lesson: what one does with it most, without opening its page first.
/** Opens the actions of a lesson on a date; null where there is no app around (previews). */
val LocalLessonActions = staticCompositionLocalOf<((Lesson, LocalDate) -> Unit)?> { null }
/**
* A lesson's tap and long press: the tap opens its page, the long press its actions, with a
* buzz. TalkBack offers both ("Подробнее", "Действия").
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun Modifier.lessonPress(lesson: Lesson, date: LocalDate?, onClick: () -> Unit): Modifier {
val actions = LocalLessonActions.current
val haptic = LocalHapticFeedback.current
if (actions == null || date == null) return clickable(onClickLabel = "Подробнее", role = Role.Button, onClick = onClick)
return combinedClickable(
onClickLabel = "Подробнее",
role = Role.Button,
onLongClickLabel = "Действия",
onLongClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
actions(lesson, date)
},
onClick = onClick,
)
}
/** Provides [LocalLessonActions] to [content] and shows the actions sheet when asked. */
@Composable
fun LessonActionsHost(vm: MainViewModel, content: @Composable () -> Unit) {
var target by remember { mutableStateOf<Pair<Lesson, LocalDate>?>(null) }
// The card pressed, so "Подробнее" grows out of it even after the sheet.
var from by remember { mutableStateOf<String?>(null) }
val morph = LocalMorph.current
val open: (Lesson, LocalDate) -> Unit = remember(morph) {
{ lesson, date ->
from = morph?.recentTouch()
target = lesson to date
}
}
CompositionLocalProvider(LocalLessonActions provides open) { content() }
target?.let { (lesson, date) -> LessonActionsSheet(vm, lesson, date, from) { target = null } }
}
@Composable
private fun LessonActionsSheet(vm: MainViewModel, lesson: Lesson, date: LocalDate, from: String?, onDismiss: () -> Unit) {
val sheet = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val scope = rememberCoroutineScope()
val context = LocalContext.current
val undo = LocalUndo.current
val morph = LocalMorph.current
val timetable by vm.timetable.collectAsStateWithLifecycle()
val settings by vm.settings.collectAsStateWithLifecycle()
val marks by vm.marks.collectAsStateWithLifecycle()
var adding by remember { mutableStateOf(false) }
val end = timetable?.let { lessonEnd(lesson, it) }
val where = if (lesson.distance) "дистанционно" else withPlace(lesson.rooms.joinToString(", ").takeIf { it.isNotEmpty() }?.let { "ауд. $it" }, lesson.place)
val summary = listOfNotNull(
date.shortWeekday().lowercase() + ", " + date.longLabel(),
lesson.start + (end?.let { " – $it" } ?: ""),
where,
).joinToString(" · ")
// Closes the sheet first, then does [then]: the next screen or dialog is not under it.
fun close(then: () -> Unit = {}) {
scope.launch { sheet.hide() }.invokeOnCompletion {
onDismiss()
then()
}
}
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheet) {
Column(Modifier.navigationBarsPadding().padding(bottom = 16.dp)) {
Column(Modifier.padding(horizontal = 24.dp).padding(bottom = 12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(lesson.subject, style = MaterialTheme.typography.titleLargeEmphasized)
Text(summary, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
ActionRow(Icons.AutoMirrored.Rounded.OpenInNew, "Подробнее") {
close {
morph?.open(MorphKind.Page, from = from) { done -> LessonPage(lesson, date, vm, done) }
}
}
if (vm.currentGroupKey() != null) ActionRow(Icons.Rounded.AddTask, "Задание или заметка") { adding = true }
// Once the lesson has begun: was there or not, in one tap.
if (vm.currentGroupKey() != null && lessonStarted(date, lesson.start)) {
val mark = marks.forLesson(lesson.subject, date, lesson.start)
ConnectedChoice(
options = listOf<Pair<Boolean?, String>>(true to "Был", false to "Пропустил"),
selected = mark?.present,
onSelect = { vm.mark(lesson, date, if (it == mark?.present) null else it) },
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 8.dp),
)
}
ActionRow(Icons.Rounded.Share, "Поделиться") {
val text = listOfNotNull(lesson.subject, lesson.type.takeIf { it.isNotEmpty() }, summary, lesson.teachers.joinToString(", ").ifEmpty { null })
.joinToString("\n")
close {
context.startActivity(Intent.createChooser(Intent(Intent.ACTION_SEND).setType("text/plain").putExtra(Intent.EXTRA_TEXT, text), null))
}
}
ActionRow(Icons.Rounded.VisibilityOff, "Скрыть такие занятия") {
close {
vm.setHidden(lesson.hideKey, true)
undo?.offer("Скрыто: «${lesson.subject}»", onUndo = { vm.setHidden(lesson.hideKey, false) })
}
}
}
}
if (adding) {
val group = vm.currentGroupKey() ?: return
NoteEditor(
target = NoteTarget(
subject = lesson.subject,
date = date,
start = lesson.start,
next = timetable?.let { vm.nextOccurrence(it, lesson.subject, date, lesson.start, settings?.subgroup) },
),
editing = null,
groupKey = group,
onSave = {
vm.saveNote(it)
adding = false
close()
},
onDelete = {},
onDismiss = { adding = false },
)
}
}
@Composable
private fun ActionRow(icon: ImageVector, label: String, onClick: () -> Unit) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable(role = Role.Button, onClick = onClick)
.padding(horizontal = 24.dp, vertical = 16.dp),
) {
Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(24.dp))
Spacer(Modifier.width(20.dp))
Text(label, style = MaterialTheme.typography.bodyLarge)
}
}

View File

@@ -0,0 +1,326 @@
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
package org.xtimms.psu.schedule.ui.lesson
import androidx.compose.animation.core.tween
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.Check
import androidx.compose.material.icons.rounded.VisibilityOff
import androidx.compose.material3.OutlinedButton
import org.xtimms.psu.schedule.data.hideKey
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.foundation.BorderStroke
import org.xtimms.psu.schedule.ui.theme.card
import org.xtimms.psu.schedule.ui.theme.page
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.EventRepeat
import androidx.compose.material.icons.rounded.Groups
import androidx.compose.material.icons.rounded.LocationCity
import androidx.compose.material.icons.rounded.LocationOn
import androidx.compose.material.icons.rounded.Person
import androidx.compose.material.icons.rounded.Schedule
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import org.xtimms.psu.schedule.data.Bell
import org.xtimms.psu.schedule.data.Lesson
import org.xtimms.psu.schedule.data.withPlace
import java.time.LocalDate
import org.xtimms.psu.schedule.ui.MainViewModel
import org.xtimms.psu.schedule.ui.components.Banner
import org.xtimms.psu.schedule.ui.components.Heading
import org.xtimms.psu.schedule.ui.components.LocalUndo
import org.xtimms.psu.schedule.ui.components.TimeText
import org.xtimms.psu.schedule.ui.components.UndoArea
import org.xtimms.psu.schedule.ui.motion.EmphasizedDecelerate
import org.xtimms.psu.schedule.ui.motion.RollingText
import org.xtimms.psu.schedule.ui.motion.morphOrigin
import org.xtimms.psu.schedule.ui.motion.progressBorder
import org.xtimms.psu.schedule.ui.motion.rememberReducedMotion
import org.xtimms.psu.schedule.ui.notes.LessonNotes
import org.xtimms.psu.schedule.ui.notes.LocalNotes
import org.xtimms.psu.schedule.ui.notes.NotesHint
import org.xtimms.psu.schedule.ui.notes.forLesson
import org.xtimms.psu.schedule.ui.lesson.forLesson
import org.xtimms.psu.schedule.ui.motion.morphContent
import androidx.compose.foundation.layout.Box
import androidx.compose.ui.draw.clip
/** A lesson as a soft card in its subject's colour, like School Planner's day list. */
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun LessonCard(
lesson: Lesson,
time: String?,
pair: Int? = null,
date: LocalDate? = null,
status: String? = null,
running: Boolean = false,
/** How far the lesson under way has gone, 0..1: a line runs round the card. */
progress: Float? = null,
onClick: (() -> Unit)?,
/** Just added or changed by a republished document: the card glows twice, then [onFreshShown]. */
fresh: Boolean = false,
onFreshShown: () -> Unit = {},
) {
val color = subjectColor(lesson.subject)
val show = LocalDisplay.current
val session = lesson.session
val content: @Composable () -> Unit = {
Column(Modifier.padding(horizontal = 20.dp, vertical = 16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
// Exams, credits and consultations announce themselves before the subject.
session?.let { SessionBadge(it, Modifier.padding(bottom = 2.dp)) }
Row(verticalAlignment = Alignment.Top) {
Text(lesson.subject, style = MaterialTheme.typography.titleMediumEmphasized, modifier = Modifier.weight(1f))
status?.let {
Spacer(Modifier.width(8.dp))
Surface(
shape = MaterialTheme.shapes.small,
color = if (running) color.onSoft else color.onSoft.copy(alpha = 0.12f),
contentColor = if (running) color.soft else color.onSoft,
) {
RollingText(it, style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp))
}
}
}
time?.let { TimeText(it + (pair?.takeIf { show.pairs }?.let { p -> " · $p пара" } ?: ""), style = MaterialTheme.typography.bodyLarge) }
FlowRow(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
if (lesson.distance) Meta(Icons.Rounded.LocationOn, "Дистанционно")
else withPlace(
lesson.rooms.takeIf { show.rooms && it.isNotEmpty() }?.let { it.joinToString(", ") + if (lesson.roomMoved) " (перенос)" else "" },
lesson.place,
)?.let { Meta(Icons.Rounded.LocationOn, it) }
if (show.teachers && lesson.teachers.isNotEmpty()) Meta(Icons.Rounded.Person, lesson.teachers.joinToString(", "))
lesson.subgroup?.let { Meta(Icons.Rounded.Groups, it) }
}
val tags = if (!show.tags) emptyList() else listOfNotNull(
lesson.type.ifEmpty { null }?.takeIf { session == null },
if (lesson.variant != "all") (if (lesson.variant == "upper") "белые недели" else "зелёные недели") else null,
if (lesson.groups.size > 1) "общее занятие" else null,
// German or French: a part of the group has this one, the rest the other.
if (lesson.option != null) "на выбор" else null,
when (lesson.audience) {
"foreign" -> "для иностранных студентов"
"local" -> "кроме иностранных студентов"
else -> null
},
)
val mark = date?.let { LocalMarks.current.forLesson(lesson.subject, it, lesson.start) }
if (tags.isNotEmpty() || lesson.uncertain || mark != null) FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.padding(top = 4.dp),
) {
mark?.let {
SoftTag(if (it.present) "был" else "пропуск", color, icon = if (it.present) Icons.Rounded.Check else Icons.Rounded.Close)
}
tags.forEach { SoftTag(it, color) }
if (lesson.uncertain) Tag("Сверьте недели", warning = true)
}
if (show.notes && lesson.condition.isNotEmpty()) Meta(Icons.Rounded.EventRepeat, "Пометка в Word: ${lesson.condition}")
if (date != null) NotesHint(LocalNotes.current.forLesson(lesson.subject, date, lesson.start), color.onSoft)
}
}
// A tapped card grows into its details page (Morph.kt).
val glow = remember { androidx.compose.animation.core.Animatable(0f) }
val reduced = rememberReducedMotion()
LaunchedEffect(fresh) {
if (!fresh) return@LaunchedEffect
if (!reduced) repeat(2) {
glow.animateTo(1f, tween(350, easing = EmphasizedDecelerate))
glow.animateTo(0f, tween(450))
}
onFreshShown()
}
// The fill itself turns tertiary and back, so the text stays as crisp as on any card.
val highlight = MaterialTheme.colorScheme.tertiaryContainer
val fill = if (glow.value > 0f) androidx.compose.ui.graphics.lerp(color.soft, highlight, glow.value) else color.soft
val morphKey = "card|$date|${lesson.key}|${lesson.start}"
val origin = if (onClick != null) Modifier.morphOrigin(morphKey, color.soft, 16.dp) else Modifier
// The line round a running card grows a little every half minute, never jumps.
val shown by androidx.compose.animation.core.animateFloatAsState(progress ?: 0f, tween(1000), label = "progress")
val live = if (running && progress != null) Modifier.progressBorder(shown, color.onSoft, 16.dp) else Modifier
val border = when {
running && progress != null -> null
running -> BorderStroke(2.dp, color.onSoft)
session != null -> BorderStroke(2.dp, session.outline())
else -> null
}
if (onClick != null) Surface(
shape = MaterialTheme.shapes.large,
color = fill,
contentColor = color.onSoft,
border = border,
// A tap opens the page, a long press the lesson's actions.
modifier = Modifier.fillMaxWidth().then(origin).then(live).clip(MaterialTheme.shapes.large).lessonPress(lesson, date, onClick),
// The page grows from the card's text; the card's fill is the container's.
content = { Box(Modifier.morphContent(morphKey)) { content() } },
) else Surface(
shape = MaterialTheme.shapes.large,
color = color.soft,
contentColor = color.onSoft,
border = border,
modifier = Modifier.fillMaxWidth(),
content = content,
)
}
@Composable
private fun SoftTag(text: String, color: SubjectColor, icon: ImageVector? = null) {
Surface(shape = MaterialTheme.shapes.small, color = color.onSoft.copy(alpha = 0.12f), contentColor = color.onSoft) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = if (icon != null) 6.dp else 8.dp, end = 8.dp, top = 3.dp, bottom = 3.dp),
) {
icon?.let {
Icon(it, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(4.dp))
}
Text(text.replaceFirstChar { it.titlecase() }, style = MaterialTheme.typography.labelLarge)
}
}
}
@Composable
private fun Meta(icon: androidx.compose.ui.graphics.vector.ImageVector, text: String) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(icon, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(6.dp))
Text(text, style = MaterialTheme.typography.bodyMedium)
}
}
@Composable
private fun TypeTag(type: String) {
val scheme = MaterialTheme.colorScheme
val (bg, fg) = when (type) {
"Лекция" -> scheme.primaryContainer to scheme.onPrimaryContainer
"Практика" -> scheme.tertiaryContainer to scheme.onTertiaryContainer
"Экзамен", "Зачёт", "Защита курсовой работы" -> sessionColors().let { it.strong to it.onStrong }
"Консультация" -> sessionColors().let { it.soft to it.onSoft }
else -> scheme.secondaryContainer to scheme.onSecondaryContainer
}
Surface(shape = MaterialTheme.shapes.small, color = bg, contentColor = fg) {
Text(type.ifEmpty { "Занятие" }, style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp))
}
}
@Composable
private fun Tag(text: String, warning: Boolean = false) {
Surface(
shape = MaterialTheme.shapes.small,
color = if (warning) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.surfaceContainerHighest,
contentColor = if (warning) MaterialTheme.colorScheme.onErrorContainer else MaterialTheme.colorScheme.onSurfaceVariant,
) {
Text(text, style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp))
}
}
@Composable
internal fun LessonDetails(
lesson: Lesson,
bells: List<Bell>,
date: LocalDate? = null,
vm: MainViewModel? = null,
onClose: () -> Unit = {},
) {
val bell = bells.firstOrNull { it.start == lesson.start }
// Hiding closes the sheet, so its "Вернуть" goes to the screen; tasks deleted here, to the sheet.
val screenUndo = LocalUndo.current
UndoArea(bottom = false) {
Column(
Modifier
.verticalScroll(rememberScrollState())
.padding(start = 24.dp, end = 24.dp, bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
TypeTag(lesson.type)
Row(verticalAlignment = Alignment.CenterVertically) {
SubjectDot(lesson.subject, 16.dp)
Spacer(Modifier.width(14.dp))
Heading(lesson.subject, style = MaterialTheme.typography.headlineSmallEmphasized)
}
Detail(
Icons.Rounded.Schedule,
bell?.let { "${it.pair} пара" } ?: "Время",
lesson.start + (lesson.end?.let { " — $it" } ?: ""),
bell?.halves?.takeIf { it.size > 1 }?.joinToString(", ") { it.joinToString("–") },
)
Detail(Icons.Rounded.Person, "Преподаватель", lesson.teachers.joinToString(", ").ifEmpty { "Не указан" })
Detail(
Icons.Rounded.LocationOn,
"Аудитория",
if (lesson.distance) "Дистанционно" else lesson.rooms.joinToString(", ").ifEmpty { "Не указана" },
if (lesson.roomMoved) "В этот день — перенос аудитории" else null,
)
lesson.place?.let { Detail(Icons.Rounded.LocationCity, "Город", it, "Так указано в Word") }
Detail(Icons.Rounded.Groups, "Группы", lesson.groups.joinToString(", "), lesson.subgroup)
if (lesson.uncertain) Banner(
"Пометку «${lesson.condition}» не удалось сопоставить с таблицей недель. Сверьте с Word.",
offline = false,
onClose = null,
)
if (date != null && vm != null) {
LessonAttendance(vm, lesson, date)
LessonNotes(vm, lesson, date)
}
Heading("Исходная запись", style = MaterialTheme.typography.titleSmallEmphasized)
Surface(shape = MaterialTheme.shapes.large, color = MaterialTheme.colorScheme.surfaceContainerHigh) {
Text(lesson.raw, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(16.dp).fillMaxWidth())
}
if (vm != null) {
OutlinedButton(onClick = {
vm.setHidden(lesson.hideKey, true)
onClose()
screenUndo?.offer("Скрыто: «${lesson.subject}»", onUndo = { vm.setHidden(lesson.hideKey, false) })
}) {
Icon(Icons.Rounded.VisibilityOff, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Скрыть такие занятия")
}
Text(
"«${lesson.subject}»${lesson.type.takeIf { it.isNotEmpty() }?.let { " · ${it.lowercase()}" }.orEmpty()} пропадёт из расписания, " +
"обзора, виджета и напоминаний во все недели. Вернуть можно в настройках.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@Composable
private fun Detail(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, value: String, extra: String? = null) {
Row {
Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 2.dp))
Spacer(Modifier.width(16.dp))
Column {
Text(label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(value, style = MaterialTheme.typography.bodyLargeEmphasized)
extra?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) }
}
}
}

View File

@@ -0,0 +1,61 @@
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
package org.xtimms.psu.schedule.ui.lesson
import androidx.compose.material.icons.rounded.Close
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.WindowInsets
import org.xtimms.psu.schedule.ui.theme.card
import org.xtimms.psu.schedule.ui.theme.page
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.xtimms.psu.schedule.data.Lesson
import java.time.LocalDate
import org.xtimms.psu.schedule.ui.MainViewModel
import org.xtimms.psu.schedule.ui.motion.LocalMorph
import org.xtimms.psu.schedule.ui.motion.MorphKind
/**
* Opens a lesson's details as a page growing out of the card that was tapped (Morph.kt); from
* anywhere else (the week grid of a narrow column, a notification) it grows from the middle.
*/
@Composable
fun rememberLessonOpener(vm: MainViewModel): (Lesson, LocalDate?) -> Unit {
val morph = LocalMorph.current
return remember(morph, vm) {
{ lesson, date -> morph?.open(MorphKind.Page) { close -> LessonPage(lesson, date, vm, close) } }
}
}
/** The details as a page: a close button over them; full screen on a phone, a panel on a tablet. */
@Composable
internal fun LessonPage(lesson: Lesson, date: LocalDate?, vm: MainViewModel, close: () -> Unit) {
val timetable by vm.timetable.collectAsStateWithLifecycle()
BoxWithConstraints(Modifier.fillMaxSize()) {
// The phone's page covers the bars; a tablet's panel sits inside them.
val insets = if (maxWidth >= 600.dp) Modifier else Modifier.windowInsetsPadding(WindowInsets.systemBars)
Column(Modifier.fillMaxSize().then(insets)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(start = 8.dp, top = 4.dp, bottom = 8.dp)) {
IconButton(onClick = close) { Icon(Icons.Rounded.Close, contentDescription = "Закрыть") }
}
LessonDetails(lesson, timetable?.bells.orEmpty(), date, vm, onClose = close)
}
}
}

View File

@@ -1,4 +1,4 @@
package org.xtimms.psu.schedule.ui
package org.xtimms.psu.schedule.ui.lesson
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -24,6 +24,7 @@ import androidx.compose.ui.unit.dp
import org.xtimms.psu.schedule.data.DayPlace
import org.xtimms.psu.schedule.data.Timetable
import java.time.LocalDate
import org.xtimms.psu.schedule.ui.MainViewModel
// "Занятия в Полоцке": Word marks the days a group spends in another town over the day's first
// row. The server reads it as a note of the day, not a lesson, and gives each lesson its town.
@@ -47,7 +48,7 @@ fun DayPlaces(places: List<DayPlace>, modifier: Modifier = Modifier) {
Icon(Icons.Rounded.LocationCity, contentDescription = null, modifier = Modifier.size(24.dp))
Spacer(Modifier.width(14.dp))
Column {
Text(p.text, style = MaterialTheme.typography.titleMediumEmphasized)
Text(p.label, style = MaterialTheme.typography.titleMediumEmphasized)
Text(
listOfNotNull(p.subgroup, "пометка в Word на этот день").joinToString(" · ").replaceFirstChar { it.titlecase() },
style = MaterialTheme.typography.bodyMedium,
@@ -67,7 +68,7 @@ fun DayPlaceMark(places: List<DayPlace>, modifier: Modifier = Modifier) {
shape = MaterialTheme.shapes.extraSmall,
color = MaterialTheme.colorScheme.tertiaryContainer,
contentColor = MaterialTheme.colorScheme.onTertiaryContainer,
modifier = modifier.semantics { contentDescription = places.joinToString(", ") { listOfNotNull(it.text, it.subgroup).joinToString(", ") } },
modifier = modifier.semantics { contentDescription = places.joinToString(", ") { listOfNotNull(it.label, it.subgroup).joinToString(", ") } },
) {
Text(
first.place,

View File

@@ -1,4 +1,4 @@
package org.xtimms.psu.schedule.ui
package org.xtimms.psu.schedule.ui.lesson
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box

View File

@@ -0,0 +1,38 @@
package org.xtimms.psu.schedule.ui.lesson
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import kotlinx.coroutines.delay
import org.xtimms.psu.schedule.data.Lesson
import org.xtimms.psu.schedule.data.Timetable
import org.xtimms.psu.schedule.data.nowTime
import org.xtimms.psu.schedule.data.parseTime
import java.time.LocalDate
import java.time.LocalTime
import org.xtimms.psu.schedule.ui.MainViewModel
/** One time slot of a day: parallel lessons of subgroups share it. */
data class Slot(val date: LocalDate, val start: LocalTime, val end: LocalTime, val lessons: List<Lesson>)
fun MainViewModel.slotsOn(t: Timetable, date: LocalDate, subgroup: String?): List<Slot> =
repository.lessonsOn(t, date, subgroup, hidden)
.groupBy { it.start }
.map { (start, lessons) -> Slot(date, parseTime(start), lessonEnd(lessons.first(), t), lessons) }
.sortedBy { it.start }
/** The current time, refreshed every half minute while the screen is shown. */
@Composable
fun rememberClock(): LocalTime {
var clock by remember { mutableStateOf(nowTime()) }
LaunchedEffect(Unit) {
while (true) {
delay(30_000)
clock = nowTime()
}
}
return clock
}

View File

@@ -1,4 +1,4 @@
package org.xtimms.psu.schedule.ui
package org.xtimms.psu.schedule.ui.lesson
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
@@ -11,6 +11,13 @@ import org.xtimms.psu.schedule.data.Lesson
import org.xtimms.psu.schedule.data.Timetable
import org.xtimms.psu.schedule.data.parseTime
import java.time.LocalTime
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.Modifier
/**
* A subject's colours: [container]/[content] for small strong fills (week grid, dots),
@@ -117,3 +124,9 @@ fun lessonEnd(lesson: Lesson, t: Timetable): LocalTime =
?: parseTime(lesson.start).plusMinutes(80)
fun Color.luminance(): Float = 0.2126f * red + 0.7152f * green + 0.0722f * blue
@Composable
fun SubjectDot(subject: String, size: Dp = 12.dp, modifier: Modifier = Modifier) {
Box(modifier.size(size).background(subjectColor(subject).container, CircleShape))
}

View File

@@ -1,8 +1,11 @@
package org.xtimms.psu.schedule.ui
package org.xtimms.psu.schedule.ui.motion
import androidx.activity.compose.PredictiveBackHandler
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.draw.shadow
import androidx.compose.animation.core.animate
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
@@ -40,6 +43,7 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.lerp
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.layer.GraphicsLayer
import androidx.compose.ui.graphics.layer.drawLayer
@@ -66,8 +70,14 @@ import kotlin.math.roundToInt
// new plan, and back. Bottom sheets live in windows of their own and cannot grow from a card, so
// these open in one overlay over the app (MorphOverlay) instead.
/** Where a transform starts: an element's bounds, its fill and corners and a picture of it. */
class MorphOrigin(val bounds: Rect, val color: Color, val radius: Dp, val picture: GraphicsLayer)
/** Where a transform starts: an element's bounds, its fill, corners and shadow and a picture of it. */
class MorphOrigin(val bounds: Rect, val color: Color, val radius: Dp, val elevation: Dp, val picture: GraphicsLayer)
/**
* The inside of an origin, without the origin's own fill and shadow: the container draws those
* itself, so a button's shape and shadow are never seen twice while it grows ([morphContent]).
*/
class MorphContent(val bounds: Rect, val picture: GraphicsLayer)
enum class MorphKind {
/** A page: the whole screen on a phone, a large panel on a tablet. */
@@ -82,6 +92,7 @@ class MorphEntry(val key: String?, val kind: MorphKind, val content: @Composable
@Stable
class MorphHost {
internal val origins = mutableMapOf<String, MorphOrigin>()
internal val contents = mutableMapOf<String, MorphContent>()
// The element the finger went down on, and when: the page grows from what was tapped.
internal var touched: String? = null
internal var touchedAt = 0L
@@ -90,11 +101,16 @@ class MorphHost {
internal var closing by mutableStateOf(false)
internal val progress = Animatable(0f)
/** Opens [content] growing out of the element tapped a moment ago, if any. */
fun open(kind: MorphKind, content: @Composable (close: () -> Unit) -> Unit) {
val key = touched?.takeIf { System.currentTimeMillis() - touchedAt < 1500 }
/** The element tapped a moment ago, if any. */
fun recentTouch(): String? = touched?.takeIf { System.currentTimeMillis() - touchedAt < 1500 }
/**
* Opens [content] growing out of [from] (an origin's key) or, by default, out of the element
* tapped a moment ago, if any.
*/
fun open(kind: MorphKind, from: String? = recentTouch(), content: @Composable (close: () -> Unit) -> Unit) {
closing = false
entry = MorphEntry(key, kind, content)
entry = MorphEntry(from, kind, content)
}
fun close() {
@@ -105,17 +121,18 @@ class MorphHost {
val LocalMorph = staticCompositionLocalOf<MorphHost?> { null }
/**
* Marks a card or a button as a place a page or a sheet grows from. While its page is open the
* element itself is hidden: the page has taken its place.
* Marks a card or a button as a place a page or a sheet grows from: its fill [color], corner
* [radius] and shadow [elevation] are the container's at the start. While its page is open the
* element itself is hidden, shadow and all: the page has taken its place.
*/
@Composable
fun Modifier.morphOrigin(key: String, color: Color, radius: Dp): Modifier {
fun Modifier.morphOrigin(key: String, color: Color, radius: Dp, elevation: Dp = 0.dp): Modifier {
val host = LocalMorph.current ?: return this
val picture = rememberGraphicsLayer()
DisposableEffect(host, key) { onDispose { host.origins.remove(key) } }
val hidden = host.entry?.key == key
return this
.onGloballyPositioned { host.origins[key] = MorphOrigin(it.boundsInRoot(), color, radius, picture) }
.onGloballyPositioned { host.origins[key] = MorphOrigin(it.boundsInRoot(), color, radius, elevation, picture) }
.pointerInput(host, key) {
awaitEachGesture {
awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
@@ -130,9 +147,30 @@ fun Modifier.morphOrigin(key: String, color: Color, radius: Dp): Modifier {
}
/**
* The open page or sheet over the app. Opening, it grows from its origin (or from the middle):
* the origin's picture fades out over the first third, the content fades in after it. Back,
* a tap on the scrim or the predictive back gesture shrink it back the same way.
* Marks what is inside an origin with the same [key] (a button's icon, a card's text): the
* container draws this picture instead of the whole element, on its own fill and shadow.
*/
@Composable
fun Modifier.morphContent(key: String): Modifier {
val host = LocalMorph.current ?: return this
val picture = rememberGraphicsLayer()
DisposableEffect(host, key) { onDispose { host.contents.remove(key) } }
return this
.onGloballyPositioned { host.contents[key] = MorphContent(it.boundsInRoot(), picture) }
.drawWithContent {
picture.record { this@drawWithContent.drawContent() }
drawLayer(picture)
}
}
/**
* The open page or sheet over the app, as the M3 container transform (MDC's
* MaterialContainerTransform). Opening, the container grows from its origin (or from the middle),
* and both contents ride in it, scaled to its width: the card's picture underneath, the page
* laid out at its final size on top, fading in over the first quarter while the corners
* straighten over three quarters. Back, a tap on the scrim, the predictive back gesture or the
* sheet's handle shrink it into the card again, the page fading out late, between 60 and 90 %
* of the way.
*/
@Composable
fun MorphOverlay(host: MorphHost) {
@@ -150,7 +188,11 @@ fun MorphOverlay(host: MorphHost) {
}
LaunchedEffect(host.closing) {
if (!host.closing) return@LaunchedEffect
host.progress.animateTo(0f, tween(if (reduced) 0 else 350, easing = Emphasized))
val spec = tween<Float>(if (reduced) 0 else 350, easing = Emphasized)
// A sheet dragged down or a page shrunk by the back gesture still lands on its origin.
launch { animate(drag, 0f, animationSpec = spec) { v, _ -> drag = v } }
launch { animate(back, 0f, animationSpec = spec) { v, _ -> back = v } }
host.progress.animateTo(0f, spec)
host.entry = null
host.closing = false
}
@@ -183,6 +225,7 @@ fun MorphOverlay(host: MorphHost) {
}
}
val origin = entry.key?.let { host.origins[it] }
val inside = entry.key?.let { host.contents[it] }
val from = origin?.bounds?.translate(-rootOffset) ?: Rect(target.center, 0f).inflate(minOf(target.width, target.height) * 0.3f)
val p = host.progress.value
val rect = lerp(from, target, p)
@@ -191,9 +234,16 @@ fun MorphOverlay(host: MorphHost) {
wide -> 28.dp
else -> 0.dp
}
val radius = lerp(origin?.radius ?: 28.dp, targetRadius, p)
val shape = if (entry.kind == MorphKind.Sheet) RoundedCornerShape(radius, radius, lerp(origin?.radius ?: 28.dp, 0.dp, p), lerp(origin?.radius ?: 28.dp, 0.dp, p))
// The thresholds of MDC's container transform, as fractions of this way (p) and back.
val closing = host.closing
val fade = (if (closing) (p - 0.1f) / 0.3f else p / 0.25f).coerceIn(0f, 1f)
val corners = (if (closing) (p - 0.1f) / 0.6f else p / 0.75f).coerceIn(0f, 1f)
val startRadius = origin?.radius ?: 28.dp
val radius = lerp(startRadius, targetRadius, corners)
val shape = if (entry.kind == MorphKind.Sheet) RoundedCornerShape(radius, radius, lerp(startRadius, 0.dp, corners), lerp(startRadius, 0.dp, corners))
else RoundedCornerShape(radius)
// Both contents are scaled to the container's width and move with it.
val pageScale = rect.width / target.width.coerceAtLeast(1f)
// The scrim; a tap on it closes.
Box(
Modifier
@@ -203,22 +253,35 @@ fun MorphOverlay(host: MorphHost) {
)
Layout(
content = {
// The content, laid out once at its final size, revealed by the growing bounds.
Box(Modifier.graphicsLayer { alpha = ((p - 0.3f) / 0.7f).coerceIn(0f, 1f) }) {
// The card's own picture underneath, growing with the container.
if (origin != null) Box(
Modifier.drawBehind {
val s = rect.width / origin.bounds.width.coerceAtLeast(1f)
scale(s, s, pivot = Offset.Zero) {
// Just the inside where it is marked: the fill and shadow are the container's.
if (inside != null) translate(inside.bounds.left - origin.bounds.left, inside.bounds.top - origin.bounds.top) {
drawLayer(inside.picture)
} else drawLayer(origin.picture)
}
},
)
// The page, laid out once at its final size and shrunk to the container's width
// from its top left corner: it comes out of the card instead of being uncovered.
Box(
Modifier
.graphicsLayer {
alpha = fade
scaleX = pageScale
scaleY = pageScale
transformOrigin = TransformOrigin(0f, 0f)
}
.background(surface),
) {
if (entry.kind == MorphKind.Sheet) SheetChrome(onDrag = { drag = (drag + it).coerceAtLeast(0f) }, onRelease = {
if (drag > with(density) { 120.dp.toPx() }) host.close() else drag = 0f
}) { entry.content(host::close) }
else entry.content(host::close)
}
// The origin's own picture, fading out as the page takes over.
if (origin != null) Box(
Modifier
.graphicsLayer { alpha = (1f - p / 0.3f).coerceIn(0f, 1f) }
.drawBehind {
val s = rect.width / origin.bounds.width.coerceAtLeast(1f)
scale(s, s, pivot = Offset.Zero) { drawLayer(origin.picture) }
},
)
},
modifier = Modifier
.offset { IntOffset(rect.left.roundToInt(), rect.top.roundToInt()) }
@@ -228,16 +291,18 @@ fun MorphOverlay(host: MorphHost) {
scaleY = s
translationY = drag
}
// The origin's shadow (a button's), fading out as it grows and back as it lands.
.shadow(lerp(origin?.elevation ?: 0.dp, 0.dp, p), shape, clip = false)
.clip(shape)
.background(lerp(origin?.color ?: surface, surface, (p / 0.5f).coerceIn(0f, 1f)))
.background(lerp(origin?.color ?: surface, surface, fade))
// Taps inside the page never reach the scrim under it.
.pointerInput(Unit) { detectTapGestures { } },
) { measurables, _ ->
val content = measurables[0].measure(Constraints.fixed(target.width.roundToInt(), target.height.roundToInt()))
val picture = measurables.getOrNull(1)?.measure(Constraints.fixed(rect.width.roundToInt().coerceAtLeast(0), rect.height.roundToInt().coerceAtLeast(0)))
val picture = if (origin != null) measurables[0].measure(Constraints.fixed(rect.width.roundToInt().coerceAtLeast(0), rect.height.roundToInt().coerceAtLeast(0))) else null
val content = measurables.last().measure(Constraints.fixed(target.width.roundToInt(), target.height.roundToInt()))
layout(rect.width.roundToInt().coerceAtLeast(0), rect.height.roundToInt().coerceAtLeast(0)) {
content.place((target.left - rect.left).roundToInt(), (target.top - rect.top).roundToInt())
picture?.place(0, 0)
content.place(0, 0)
}
}
}

View File

@@ -1,4 +1,4 @@
package org.xtimms.psu.schedule.ui
package org.xtimms.psu.schedule.ui.motion
import androidx.compose.ui.Modifier
import androidx.compose.material3.MaterialTheme

View File

@@ -1,6 +1,6 @@
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
package org.xtimms.psu.schedule.ui
package org.xtimms.psu.schedule.ui.notes
import kotlinx.coroutines.launch
import kotlinx.coroutines.delay
@@ -73,6 +73,20 @@ import org.xtimms.psu.schedule.data.shortWeekday
import org.xtimms.psu.schedule.data.today
import org.xtimms.psu.schedule.data.weekdayName
import java.time.LocalDate
import org.xtimms.psu.schedule.ui.MainViewModel
import org.xtimms.psu.schedule.ui.components.ConnectedChoice
import org.xtimms.psu.schedule.ui.components.DONE_PAUSE
import org.xtimms.psu.schedule.ui.components.Heading
import org.xtimms.psu.schedule.ui.components.LocalUndo
import org.xtimms.psu.schedule.ui.components.StrikeText
import org.xtimms.psu.schedule.ui.lesson.SubjectDot
import org.xtimms.psu.schedule.ui.lesson.subjectKey
import org.xtimms.psu.schedule.ui.components.SwipeAction
import org.xtimms.psu.schedule.ui.components.SwipeActions
import org.xtimms.psu.schedule.ui.components.UndoHost
import androidx.compose.material.icons.rounded.Check
import androidx.compose.material.icons.automirrored.rounded.Undo
import androidx.compose.material.icons.rounded.EventRepeat
/** Tasks and notes of the open group, for cards deep in the tree. */
val LocalNotes = staticCompositionLocalOf<List<Note>> { emptyList() }
@@ -118,15 +132,48 @@ fun MainViewModel.nextOccurrence(t: Timetable, subject: String, after: LocalDate
.firstOrNull()
}
/** A task with its checkbox or a note with its icon; tap opens the editor. */
/**
* A task with its checkbox or a note with its icon; tap opens the editor. Swiped to the right a
* task is done (or open again); swiped to the left it moves to the next lesson of its subject with
* [onPostpone]. [swipe] is off where a sideways swipe pages, as on the plans board.
*/
@Composable
fun NoteRow(note: Note, onToggle: () -> Unit, onClick: () -> Unit, showSubject: Boolean, showDue: Boolean = true) {
fun NoteRow(
note: Note,
onToggle: () -> Unit,
onClick: () -> Unit,
showSubject: Boolean,
showDue: Boolean = true,
onPostpone: ((UndoHost?) -> Unit)? = null,
swipe: Boolean = true,
) {
val overdue = note.overdue()
var viewing by remember { mutableStateOf(false) }
val haptic = LocalHapticFeedback.current
// A ticked task is struck through in place first, then moves down to the done ones.
var shownDone by remember(note.id, note.done) { mutableStateOf(note.done) }
val scope = rememberCoroutineScope()
val undo = LocalUndo.current
fun check(done: Boolean) {
haptic.performHapticFeedback(if (done) HapticFeedbackType.Confirm else HapticFeedbackType.ToggleOff)
shownDone = done
if (done) scope.launch {
delay(DONE_PAUSE)
onToggle()
} else onToggle()
}
val scheme = MaterialTheme.colorScheme
val doneAction = if (!swipe || !note.task) null
else if (!shownDone) SwipeAction(Icons.Rounded.Check, "Выполнено", scheme.primary, scheme.onPrimary) { check(true) }
else SwipeAction(Icons.AutoMirrored.Rounded.Undo, "Не выполнено", scheme.secondary, scheme.onSecondary) { check(false) }
val laterAction = if (!swipe || onPostpone == null || (note.task && shownDone)) null
else SwipeAction(
Icons.Rounded.EventRepeat,
if (note.general) "Перенести на завтра" else "Перенести к следующей паре",
scheme.tertiary,
scheme.onTertiary,
) { onPostpone(undo) }
SwipeActions(doneAction, laterAction) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
@@ -135,14 +182,7 @@ fun NoteRow(note: Note, onToggle: () -> Unit, onClick: () -> Unit, showSubject:
.clickable(onClickLabel = "Изменить", role = Role.Button, onClick = onClick)
.padding(start = 8.dp, end = 16.dp, top = 2.dp, bottom = 2.dp),
) {
if (note.task) Checkbox(checked = shownDone, onCheckedChange = { done ->
haptic.performHapticFeedback(if (done) HapticFeedbackType.Confirm else HapticFeedbackType.ToggleOff)
shownDone = done
if (done) scope.launch {
delay(DONE_PAUSE)
onToggle()
} else onToggle()
})
if (note.task) Checkbox(checked = shownDone, onCheckedChange = ::check)
else Box(Modifier.size(48.dp), contentAlignment = Alignment.Center) {
Icon(Icons.Rounded.StickyNote2, contentDescription = "Заметка", tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
@@ -173,17 +213,8 @@ fun NoteRow(note: Note, onToggle: () -> Unit, onClick: () -> Unit, showSubject:
}
// The first photo, with how many more; it opens the viewer, the row the editor.
if (note.photos.isNotEmpty()) Box(Modifier.padding(start = 12.dp)) {
PhotoThumb(note.photos.first(), 48.dp, onClick = { viewing = true })
if (note.photos.size > 1) Text(
"+${note.photos.size - 1}",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.inverseOnSurface,
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(3.dp)
.background(MaterialTheme.colorScheme.inverseSurface.copy(alpha = 0.85f), MaterialTheme.shapes.small)
.padding(horizontal = 5.dp),
)
PhotoThumb(note.photos.first(), 48.dp, onClick = { viewing = true }, more = note.photos.size - 1)
}
}
}
if (viewing) PhotoViewer(note.photos, 0) { viewing = false }
@@ -350,7 +381,7 @@ fun LessonNotes(vm: MainViewModel, lesson: Lesson, date: LocalDate) {
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
notes.forEach { note ->
NoteRow(note, onToggle = { vm.toggleNote(note.id) }, onClick = { editing = note }, showSubject = false)
NoteRow(note, onToggle = { vm.toggleNote(note.id) }, onClick = { editing = note }, showSubject = false, onPostpone = { vm.postponeNote(note, it) })
}
FilledTonalButton(onClick = { adding = true }, modifier = Modifier.padding(top = 8.dp)) {
Icon(Icons.Rounded.Add, contentDescription = null, modifier = Modifier.size(18.dp))

View File

@@ -0,0 +1,472 @@
package org.xtimms.psu.schedule.ui.notes
import android.graphics.BitmapFactory
import android.net.Uri
import android.util.LruCache
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.calculatePan
import androidx.compose.foundation.gestures.calculateZoom
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.PhotoCamera
import androidx.compose.material.icons.rounded.PhotoLibrary
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChanged
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.core.content.FileProvider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.xtimms.psu.schedule.app
import java.io.File
import androidx.activity.compose.PredictiveBackHandler
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.animate
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.geometry.lerp
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.window.DialogWindowProvider
import androidx.core.view.WindowCompat
import kotlinx.coroutines.CancellationException
import org.xtimms.psu.schedule.ui.motion.Emphasized
import org.xtimms.psu.schedule.ui.motion.rememberReducedMotion
import kotlin.math.roundToInt
// Photos of the board or the assignment, pinned to a task or a note (NotesStore keeps the files).
/** Decoded thumbnails, so lists scrolling past the same photos do not decode them again. */
private val thumbnails = object : LruCache<String, ImageBitmap>(24 * 1024 * 1024) {
override fun sizeOf(key: String, value: ImageBitmap) = value.width * value.height * 4
}
/** A photo decoded no larger than [max] pixels on its long side; null while it loads. */
@Composable
private fun rememberPhoto(file: File, max: Int): ImageBitmap? {
val key = "${file.name}@$max"
return produceState(thumbnails.get(key), key) {
// The state outlives a change of photo: never keep showing the previous one.
thumbnails.get(key)?.let {
value = it
return@produceState
}
value = null
value = withContext(Dispatchers.IO) {
runCatching {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(file.path, bounds)
var sample = 1
while (maxOf(bounds.outWidth, bounds.outHeight) / (sample * 2) >= max) sample *= 2
BitmapFactory.decodeFile(file.path, BitmapFactory.Options().apply { inSampleSize = sample })?.asImageBitmap()
}.getOrNull()
}?.also { thumbnails.put(key, it) }
}.value
}
/**
* Where each photo's thumbnail is on the screen, so the viewer grows out of it and shrinks back
* into it (the M3 container transform for a hero moment). Screen coordinates, since a thumbnail
* may sit in a dialog (the editor) and the viewer is a window of its own.
*/
private object PhotoHero {
val bounds = mutableMapOf<String, Rect>()
/** The photo the viewer has taken over: its thumbnail steps aside meanwhile. */
var hidden by mutableStateOf<String?>(null)
}
/** A square thumbnail; [onClick] opens the viewer. [more] photos behind it show as "+2". */
@Composable
fun PhotoThumb(name: String, size: Dp, onClick: (() -> Unit)?, modifier: Modifier = Modifier, more: Int = 0) {
val store = LocalContext.current.app.notes
val px = with(LocalDensity.current) { size.roundToPx() } * 2
val image = rememberPhoto(store.photo(name), px)
val view = LocalView.current
val placed = remember { mutableStateOf<Rect?>(null) }
DisposableEffect(name) {
onDispose { if (PhotoHero.bounds[name] == placed.value) PhotoHero.bounds.remove(name) }
}
Box(
modifier
.size(size)
.onGloballyPositioned { c ->
val at = IntArray(2).also(view::getLocationOnScreen)
val rect = c.boundsInRoot().translate(at[0].toFloat(), at[1].toFloat())
placed.value = rect
PhotoHero.bounds[name] = rect
}
.graphicsLayer { alpha = if (PhotoHero.hidden == name) 0f else 1f }
.clip(MaterialTheme.shapes.medium)
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
.then(if (onClick != null) Modifier.clickable(onClickLabel = "Открыть фото", onClick = onClick) else Modifier),
) {
image?.let { Image(it, contentDescription = "Фото", contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize()) }
if (more > 0) Text(
"+$more",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.inverseOnSurface,
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(3.dp)
.background(MaterialTheme.colorScheme.inverseSurface.copy(alpha = 0.85f), MaterialTheme.shapes.small)
.padding(horizontal = 5.dp),
)
}
}
/**
* The editor's photos: thumbnails with a remove button, then "Снять" (the camera app) and
* "Галерея" (the system photo picker, no storage permission needed).
*/
@Composable
fun NotePhotosEditor(photos: List<String>, onAdd: (String) -> Unit, onRemove: (String) -> Unit) {
val context = LocalContext.current
val store = context.app.notes
val scope = rememberCoroutineScope()
var loading by remember { mutableIntStateOf(0) }
var viewing by remember { mutableStateOf<Int?>(null) }
fun import(uris: List<Uri>) {
uris.forEach { uri ->
loading++
scope.launch {
store.importPhoto(uri)?.let(onAdd)
loading--
}
}
}
// The camera app writes the full-size shot here; the store keeps a smaller copy.
val shot = remember { File(context.cacheDir, "camera/shot.jpg") }
val camera = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { taken ->
if (taken) {
loading++
scope.launch {
store.importPhoto(Uri.fromFile(shot))?.let(onAdd)
shot.delete()
loading--
}
}
}
val gallery = rememberLauncherForActivityResult(ActivityResultContracts.PickMultipleVisualMedia(MAX_PHOTOS)) { import(it) }
val room = MAX_PHOTOS - photos.size
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
if (photos.isNotEmpty() || loading > 0) Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.horizontalScroll(rememberScrollState()),
) {
photos.forEachIndexed { index, name ->
Box {
PhotoThumb(name, 72.dp, onClick = { viewing = index })
FilledIconButton(
onClick = { onRemove(name) },
colors = IconButtonDefaults.filledIconButtonColors(
containerColor = MaterialTheme.colorScheme.inverseSurface,
contentColor = MaterialTheme.colorScheme.inverseOnSurface,
),
modifier = Modifier.align(Alignment.TopEnd).padding(4.dp).size(24.dp),
) { Icon(Icons.Rounded.Close, contentDescription = "Убрать фото", modifier = Modifier.size(16.dp)) }
}
}
repeat(loading) {
Box(
Modifier.size(72.dp).clip(MaterialTheme.shapes.medium).background(MaterialTheme.colorScheme.surfaceContainerHighest),
contentAlignment = Alignment.Center,
) { CircularProgressIndicator(Modifier.size(24.dp), strokeWidth = 2.dp) }
}
}
if (room > 0) Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
OutlinedButton(
onClick = {
shot.parentFile?.mkdirs()
val uri = FileProvider.getUriForFile(context, context.packageName + ".photos", shot)
// A phone without a camera app: the gallery is still there.
runCatching { camera.launch(uri) }
},
modifier = Modifier.weight(1f),
) {
Icon(Icons.Rounded.PhotoCamera, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Снять", maxLines = 1)
}
OutlinedButton(
onClick = { gallery.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) },
modifier = Modifier.weight(1f),
) {
Icon(Icons.Rounded.PhotoLibrary, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Галерея", maxLines = 1)
}
}
}
viewing?.let { PhotoViewer(photos, it) { viewing = null } }
}
/**
* Photos full screen, swiped sideways; pinch or double-tap to zoom into the board. It opens as a
* container transform from the thumbnail that was tapped: the photo grows from its square crop
* into the whole picture, its corners straighten and the black comes up around it. Closing
* shrinks the photo shown back into its thumbnail, or fades it away when that one is not there.
*/
@Composable
fun PhotoViewer(photos: List<String>, start: Int, onDismiss: () -> Unit) {
if (photos.isEmpty()) return
var closing by remember { mutableStateOf(false) }
Dialog(
onDismissRequest = { closing = true },
// Edge to edge and with no dim of its own: the viewer draws its own black.
properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false),
) {
val view = LocalView.current
(view.parent as? DialogWindowProvider)?.window?.let { window ->
SideEffect {
window.setDimAmount(0f)
// The transform is the whole animation: no window fade on top of it.
window.setWindowAnimations(0)
// Light status bar icons over the black.
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = false
}
}
PhotoPages(photos, start, closing, onClose = { closing = true }, onClosed = onDismiss)
}
}
@Composable
private fun PhotoPages(photos: List<String>, start: Int, closing: Boolean, onClose: () -> Unit, onClosed: () -> Unit) {
val store = LocalContext.current.app.notes
val reduced = rememberReducedMotion()
val pager = rememberPagerState(initialPage = start.coerceIn(0, photos.lastIndex)) { photos.size }
val name = photos[pager.currentPage]
val image = rememberPhoto(store.photo(name), 2048)
// The thumbnail the photo shown returns to: its own, or the one the viewer opened from (a
// task's row shows only its first photo).
val anchor = if (PhotoHero.bounds.containsKey(name)) name else photos[start.coerceIn(0, photos.lastIndex)]
// Returning to another photo's thumbnail, the photo turns into that one on the last stretch.
val anchorImage = if (anchor != name && closing) rememberPhoto(store.photo(anchor), 1024) else null
val progress = remember { Animatable(0f) }
var back by remember { mutableFloatStateOf(0f) }
var opened by remember { mutableStateOf(false) }
var gone by remember { mutableStateOf(false) }
val view = LocalView.current
val scope = rememberCoroutineScope()
// Opens once the photo is decoded, so it grows as itself and not as an empty box.
LaunchedEffect(image != null) {
if (image == null || opened) return@LaunchedEffect
PhotoHero.hidden = name
progress.animateTo(1f, if (reduced) snap() else tween(450, easing = Emphasized))
opened = true
PhotoHero.hidden = null
}
LaunchedEffect(closing) {
if (!closing) return@LaunchedEffect
PhotoHero.hidden = anchor
progress.animateTo(0f, if (reduced) snap() else tween(350, easing = Emphasized))
// The thumbnail takes over again before the window goes.
gone = true
PhotoHero.hidden = null
withFrameNanos {}
onClosed()
}
PredictiveBackHandler(enabled = !closing) { events ->
try {
events.collect { back = it.progress }
onClose()
} catch (e: CancellationException) {
scope.launch { animate(back, 0f) { v, _ -> back = v } }
throw e
}
}
BoxWithConstraints(Modifier.fillMaxSize()) {
val w = constraints.maxWidth.toFloat()
val h = constraints.maxHeight.toFloat()
val p = progress.value
Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = p)))
val hero = !opened || closing
if (hero && image != null && !gone) {
// The whole photo on the screen, and the thumbnail it grows from, in this window.
val fit = minOf(w / image.width, h / image.height)
val target = Rect(Offset((w - image.width * fit) / 2, (h - image.height * fit) / 2), Size(image.width * fit, image.height * fit))
val at = IntArray(2).also(view::getLocationOnScreen)
val from = PhotoHero.bounds[anchor]?.translate(-at[0].toFloat(), -at[1].toFloat())
val rect = if (from != null) lerp(from, target, p) else target
val corner = with(LocalDensity.current) { (12.dp * (1f - p)).toPx() }
Image(
image,
contentDescription = null,
// A square crop at the thumbnail, the whole photo on the screen.
contentScale = ContentScale.Crop,
modifier = Modifier
.offset { IntOffset(rect.left.roundToInt(), rect.top.roundToInt()) }
.size(with(LocalDensity.current) { rect.width.toDp() }, with(LocalDensity.current) { rect.height.toDp() })
.graphicsLayer {
// With no thumbnail to return to, it fades and settles a little smaller.
if (from == null) {
alpha = p
val s = 0.9f + 0.1f * p
scaleX = s
scaleY = s
}
shape = RoundedCornerShape(corner)
clip = true
},
)
if (from != null && anchorImage != null) Image(
anchorImage,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.offset { IntOffset(rect.left.roundToInt(), rect.top.roundToInt()) }
.size(with(LocalDensity.current) { rect.width.toDp() }, with(LocalDensity.current) { rect.height.toDp() })
.graphicsLayer {
alpha = (1f - p / 0.35f).coerceIn(0f, 1f)
shape = RoundedCornerShape(corner)
clip = true
},
)
}
if (!hero) Box(
Modifier.fillMaxSize().graphicsLayer {
// The predictive back gesture shrinks the photo a little before it lets go.
val s = 1f - 0.1f * back
scaleX = s
scaleY = s
},
) {
HorizontalPager(state = pager, modifier = Modifier.fillMaxSize()) { page ->
ZoomablePhoto(photos[page], page, photos.size)
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.graphicsLayer { alpha = ((p - 0.5f) / 0.5f).coerceIn(0f, 1f) }
.statusBarsPadding()
.padding(8.dp),
) {
IconButton(onClick = onClose) { Icon(Icons.Rounded.Close, contentDescription = "Закрыть", tint = Color.White) }
Spacer(Modifier.weight(1f))
if (photos.size > 1) Surface(shape = CircleShape, color = Color.Black.copy(alpha = 0.5f), contentColor = Color.White) {
Text("${pager.currentPage + 1} / ${photos.size}", style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp))
}
}
}
}
/** One photo of the viewer: two fingers zoom, a double tap zooms in and out. */
@Composable
private fun ZoomablePhoto(name: String, page: Int, count: Int) {
val store = LocalContext.current.app.notes
var scale by remember { mutableFloatStateOf(1f) }
var offset by remember { mutableStateOf(Offset.Zero) }
val image = rememberPhoto(store.photo(name), 2048)
Box(
Modifier
.fillMaxSize()
.pointerInput(Unit) {
detectTapGestures(onDoubleTap = {
if (scale > 1f) { scale = 1f; offset = Offset.Zero } else scale = 2.5f
})
}
.pointerInput(Unit) {
// Two fingers zoom; one finger pans only while zoomed in, so at 1x the
// pager still takes the swipe to the next photo.
awaitEachGesture {
awaitFirstDown(requireUnconsumed = false)
do {
val event = awaitPointerEvent()
if (event.changes.size > 1 || scale > 1f) {
scale = (scale * event.calculateZoom()).coerceIn(1f, 5f)
offset = if (scale > 1f) offset + event.calculatePan() else Offset.Zero
event.changes.forEach { if (it.positionChanged()) it.consume() }
}
} while (event.changes.any { it.pressed })
}
},
contentAlignment = Alignment.Center,
) {
if (image == null) CircularProgressIndicator(color = Color.White)
else Image(
image,
contentDescription = "Фото ${page + 1} из $count",
contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxSize().graphicsLayer {
scaleX = scale
scaleY = scale
translationX = offset.x
translationY = offset.y
},
)
}
}
/** Up to this many photos per task or note. */
const val MAX_PHOTOS = 6

View File

@@ -1,6 +1,6 @@
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
package org.xtimms.psu.schedule.ui
package org.xtimms.psu.schedule.ui.onboarding
import android.Manifest
import android.os.Build
@@ -93,6 +93,17 @@ import org.xtimms.psu.schedule.notify.Reminders
import org.xtimms.psu.schedule.ui.theme.WeekColors
import org.xtimms.psu.schedule.ui.theme.page
import kotlin.coroutines.cancellation.CancellationException
import org.xtimms.psu.schedule.ui.MainViewModel
import org.xtimms.psu.schedule.ui.components.ConnectedChoice
import org.xtimms.psu.schedule.ui.components.Heading
import org.xtimms.psu.schedule.ui.components.SegmentedItem
import org.xtimms.psu.schedule.ui.components.SegmentedList
import org.xtimms.psu.schedule.ui.components.navBarPadding
import org.xtimms.psu.schedule.ui.groups.GroupsScreen
import org.xtimms.psu.schedule.ui.groups.choiceText
import org.xtimms.psu.schedule.ui.groups.foreignText
import org.xtimms.psu.schedule.ui.motion.materialSharedAxisX
import org.xtimms.psu.schedule.ui.motion.rememberSlideDistance
/** The first run, in the order a student needs it: what this is, their group, their subgroup, reminders. */
private enum class Step { Welcome, Group, Subgroup, Choice, Foreign, Reminders }

View File

@@ -1,6 +1,6 @@
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
package org.xtimms.psu.schedule.ui
package org.xtimms.psu.schedule.ui.plans
import androidx.compose.animation.AnimatedContent
import androidx.compose.ui.unit.Dp
@@ -101,6 +101,44 @@ import java.time.LocalDate
import java.time.YearMonth
import java.time.format.DateTimeFormatter
import java.util.Locale
import org.xtimms.psu.schedule.ui.AppNav
import org.xtimms.psu.schedule.ui.MainViewModel
import org.xtimms.psu.schedule.ui.MenuButton
import org.xtimms.psu.schedule.ui.components.CenteredEmpty
import org.xtimms.psu.schedule.ui.components.ContentColumn
import org.xtimms.psu.schedule.ui.components.DONE_PAUSE
import org.xtimms.psu.schedule.ui.components.DetailPane
import org.xtimms.psu.schedule.ui.components.Empty
import org.xtimms.psu.schedule.ui.components.Heading
import org.xtimms.psu.schedule.ui.components.LocalPaneColor
import org.xtimms.psu.schedule.ui.components.LocalUndo
import org.xtimms.psu.schedule.ui.components.MAX_CONTENT
import org.xtimms.psu.schedule.ui.components.PaneRow
import org.xtimms.psu.schedule.ui.components.StrikeText
import org.xtimms.psu.schedule.ui.components.TimeText
import org.xtimms.psu.schedule.ui.components.navBarPadding
import org.xtimms.psu.schedule.ui.components.twoPane
import org.xtimms.psu.schedule.ui.components.weekTintOf
import org.xtimms.psu.schedule.ui.lesson.DayPlaces
import org.xtimms.psu.schedule.ui.lesson.SessionDot
import org.xtimms.psu.schedule.ui.lesson.placesOn
import org.xtimms.psu.schedule.ui.lesson.rememberLessonOpener
import org.xtimms.psu.schedule.ui.lesson.session
import org.xtimms.psu.schedule.ui.lesson.sessionColors
import org.xtimms.psu.schedule.ui.lesson.slotsOn
import org.xtimms.psu.schedule.ui.lesson.subjectColor
import org.xtimms.psu.schedule.ui.motion.AnimatedItem
import org.xtimms.psu.schedule.ui.motion.Emphasized
import org.xtimms.psu.schedule.ui.motion.LocalMorph
import org.xtimms.psu.schedule.ui.motion.MorphKind
import org.xtimms.psu.schedule.ui.motion.MotionDuration
import org.xtimms.psu.schedule.ui.motion.materialSharedAxisX
import org.xtimms.psu.schedule.ui.motion.morphOrigin
import org.xtimms.psu.schedule.ui.motion.rememberSlideDistance
import org.xtimms.psu.schedule.ui.notes.NoteEditor
import org.xtimms.psu.schedule.ui.notes.NoteTarget
import org.xtimms.psu.schedule.ui.notes.general
import org.xtimms.psu.schedule.ui.motion.morphContent
private val MONTH = DateTimeFormatter.ofPattern("LLLL yyyy", Locale.forLanguageTag("ru"))
private const val MONTHS_BEFORE = 2
@@ -318,8 +356,8 @@ fun CalendarScreen(vm: MainViewModel, nav: AppNav) {
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(end = 16.dp, bottom = 16.dp + navBarPadding())
.morphOrigin("fab|calendar", MaterialTheme.colorScheme.primaryContainer, 16.dp),
) { Icon(Icons.Rounded.Add, contentDescription = "Новый план") }
.morphOrigin("fab|calendar", MaterialTheme.colorScheme.primaryContainer, 16.dp, elevation = 6.dp),
) { Icon(Icons.Rounded.Add, contentDescription = "Новый план", modifier = Modifier.morphContent("fab|calendar")) }
}
target?.let { tg ->

View File

@@ -1,6 +1,6 @@
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
package org.xtimms.psu.schedule.ui
package org.xtimms.psu.schedule.ui.plans
import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.fadeOut
@@ -96,6 +96,38 @@ import org.xtimms.psu.schedule.data.weekdayName
import org.xtimms.psu.schedule.ui.theme.card
import org.xtimms.psu.schedule.ui.theme.page
import java.time.LocalDate
import org.xtimms.psu.schedule.ui.AppNav
import org.xtimms.psu.schedule.ui.MainViewModel
import org.xtimms.psu.schedule.ui.MenuButton
import org.xtimms.psu.schedule.ui.components.CenteredEmpty
import org.xtimms.psu.schedule.ui.components.DetailPane
import org.xtimms.psu.schedule.ui.components.DoneMark
import org.xtimms.psu.schedule.ui.components.Empty
import org.xtimms.psu.schedule.ui.components.Heading
import org.xtimms.psu.schedule.ui.components.LocalUndo
import org.xtimms.psu.schedule.ui.components.PaneRow
import org.xtimms.psu.schedule.ui.components.navBarPadding
import org.xtimms.psu.schedule.ui.components.twoPane
import org.xtimms.psu.schedule.ui.components.weekTint
import org.xtimms.psu.schedule.ui.home.CompactLesson
import org.xtimms.psu.schedule.ui.lesson.SessionDot
import org.xtimms.psu.schedule.ui.lesson.Slot
import org.xtimms.psu.schedule.ui.lesson.rememberLessonOpener
import org.xtimms.psu.schedule.ui.lesson.session
import org.xtimms.psu.schedule.ui.lesson.sessionColors
import org.xtimms.psu.schedule.ui.lesson.slotsOn
import org.xtimms.psu.schedule.ui.lesson.subjectColor
import org.xtimms.psu.schedule.ui.motion.LocalMorph
import org.xtimms.psu.schedule.ui.motion.MorphKind
import org.xtimms.psu.schedule.ui.motion.materialFadeThrough
import org.xtimms.psu.schedule.ui.motion.morphOrigin
import org.xtimms.psu.schedule.ui.notes.NoteEditor
import org.xtimms.psu.schedule.ui.notes.NoteRow
import org.xtimms.psu.schedule.ui.notes.NoteTarget
import org.xtimms.psu.schedule.ui.search.SearchWeekStrip
import org.xtimms.psu.schedule.ui.notes.overdue
import org.xtimms.psu.schedule.ui.components.UndoHost
import org.xtimms.psu.schedule.ui.motion.morphContent
/** "Сегодня • Суббота • 17 октября", "Завтра • …" or "Понедельник • 19 октября". */
private fun dayTitle(d: LocalDate): String = when (d) {
@@ -222,7 +254,7 @@ fun PlansScreen(vm: MainViewModel, nav: AppNav) {
rows.getOrNull(i)?.date?.let { if (it != date) date = it }
}
}
PlanList(list, rows, vm::toggleNote, onEdit = { editing = it }, color = color)
PlanList(list, rows, vm::toggleNote, onEdit = { editing = it }, onPostpone = vm::postponeNote, color = color)
}
BoxWithConstraints(Modifier.fillMaxSize()) {
@@ -285,8 +317,8 @@ fun PlansScreen(vm: MainViewModel, nav: AppNav) {
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(end = 16.dp, bottom = 16.dp + navBarPadding())
.morphOrigin("fab|plans", MaterialTheme.colorScheme.primaryContainer, 16.dp),
) { Icon(Icons.Rounded.Add, contentDescription = "Новое задание") }
.morphOrigin("fab|plans", MaterialTheme.colorScheme.primaryContainer, 16.dp, elevation = 6.dp),
) { Icon(Icons.Rounded.Add, contentDescription = "Новое задание", modifier = Modifier.morphContent("fab|plans")) }
}
if (tuning) ModalBottomSheet(onDismissRequest = { tuning = false }) { PlansSettings(prefs, vm::setPlans) }
@@ -332,6 +364,7 @@ private fun PlanList(
rows: List<Row>,
onToggle: (String) -> Unit,
onEdit: (Note) -> Unit,
onPostpone: (Note, UndoHost?) -> Unit,
color: Color = MaterialTheme.colorScheme.surface,
) {
if (rows.isEmpty()) {
@@ -356,7 +389,14 @@ private fun PlanList(
}
is Row.Item -> item(key = "n${row.note.id}") {
Box(Modifier.animateItem()) {
NoteRow(row.note, onToggle = { onToggle(row.note.id) }, onClick = { onEdit(row.note) }, showSubject = true, showDue = row.date == null)
NoteRow(
row.note,
onToggle = { onToggle(row.note.id) },
onClick = { onEdit(row.note) },
showSubject = true,
showDue = row.date == null,
onPostpone = { onPostpone(row.note, it) },
)
}
}
is Row.Divider -> item(key = "d$day") {
@@ -471,7 +511,8 @@ private fun Board(
color = MaterialTheme.colorScheme.card,
modifier = Modifier.fillMaxWidth().border(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), MaterialTheme.shapes.large),
) {
NoteRow(n, onToggle = { onToggle(n.id) }, onClick = { onEdit(n) }, showSubject = true, showDue = d == null)
// The board's columns page sideways: a sideways swipe on a task would fight them.
NoteRow(n, onToggle = { onToggle(n.id) }, onClick = { onEdit(n) }, showSubject = true, showDue = d == null, swipe = false)
}
}
}

View File

@@ -1,12 +1,10 @@
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
package org.xtimms.psu.schedule.ui
package org.xtimms.psu.schedule.ui.schedule
import androidx.compose.animation.core.tween
import kotlinx.coroutines.flow.drop
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.unit.Dp
import androidx.compose.animation.togetherWith
import androidx.compose.animation.Crossfade
import androidx.compose.animation.shrinkVertically
@@ -16,29 +14,16 @@ import androidx.compose.animation.expandVertically
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.AnimatedContent
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.foundation.layout.heightIn
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.Check
import androidx.compose.foundation.interaction.collectIsDraggedAsState
import androidx.compose.material.icons.rounded.VisibilityOff
import androidx.compose.material3.OutlinedButton
import org.xtimms.psu.schedule.data.hideKey
import org.xtimms.psu.schedule.data.hiddenIn
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.graphics.Color
import kotlin.math.abs
import androidx.compose.material.icons.rounded.Weekend
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialShapes
import androidx.compose.material3.toShape
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.widthIn
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.material.icons.rounded.Today
@@ -50,12 +35,8 @@ import org.xtimms.psu.schedule.ui.theme.page
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -71,27 +52,17 @@ import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.CloudOff
import androidx.compose.material.icons.rounded.ErrorOutline
import androidx.compose.material.icons.rounded.EventBusy
import androidx.compose.material.icons.rounded.EventRepeat
import androidx.compose.material.icons.rounded.Groups
import androidx.compose.material.icons.rounded.LocationCity
import androidx.compose.material.icons.rounded.LocationOn
import androidx.compose.material.icons.rounded.Person
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material.icons.rounded.CalendarViewWeek
import androidx.compose.material.icons.rounded.ViewDay
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.material.icons.rounded.Schedule
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -106,7 +77,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.launch
import org.xtimms.psu.schedule.data.Bell
import org.xtimms.psu.schedule.data.DayPlace
import org.xtimms.psu.schedule.data.Lesson
import org.xtimms.psu.schedule.data.Timetable
@@ -116,9 +86,40 @@ import org.xtimms.psu.schedule.data.minutesLabel
import org.xtimms.psu.schedule.data.monday
import org.xtimms.psu.schedule.data.parseDate
import org.xtimms.psu.schedule.data.today
import org.xtimms.psu.schedule.data.withPlace
import java.time.Duration
import java.time.LocalDate
import org.xtimms.psu.schedule.ui.AppNav
import org.xtimms.psu.schedule.ui.MainViewModel
import org.xtimms.psu.schedule.ui.MenuButton
import org.xtimms.psu.schedule.ui.components.Banner
import org.xtimms.psu.schedule.ui.components.CenteredEmpty
import org.xtimms.psu.schedule.ui.components.ContentColumn
import org.xtimms.psu.schedule.ui.components.DayStrip
import org.xtimms.psu.schedule.ui.components.Empty
import org.xtimms.psu.schedule.ui.components.FoldSplit
import org.xtimms.psu.schedule.ui.components.Heading
import org.xtimms.psu.schedule.ui.components.LocalFoldTablet
import org.xtimms.psu.schedule.ui.components.LocalHinge
import org.xtimms.psu.schedule.ui.components.MAX_CONTENT
import org.xtimms.psu.schedule.ui.components.ScheduleSkeleton
import org.xtimms.psu.schedule.ui.components.TWO_PANE
import org.xtimms.psu.schedule.ui.components.WeekMark
import org.xtimms.psu.schedule.ui.components.appBarColors
import org.xtimms.psu.schedule.ui.components.navBarPadding
import org.xtimms.psu.schedule.ui.components.trackFold
import org.xtimms.psu.schedule.ui.components.weekTint
import org.xtimms.psu.schedule.ui.components.weekTintOf
import org.xtimms.psu.schedule.ui.lesson.DayPlaces
import org.xtimms.psu.schedule.ui.lesson.LessonCard
import org.xtimms.psu.schedule.ui.lesson.BetweenRow
import org.xtimms.psu.schedule.ui.lesson.between
import org.xtimms.psu.schedule.ui.lesson.Slot
import org.xtimms.psu.schedule.ui.lesson.placesOn
import org.xtimms.psu.schedule.ui.lesson.rememberClock
import org.xtimms.psu.schedule.ui.lesson.rememberLessonOpener
import org.xtimms.psu.schedule.ui.lesson.slotsOn
import org.xtimms.psu.schedule.ui.motion.AnimatedItem
import org.xtimms.psu.schedule.ui.motion.ExpressiveRefresh
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
@@ -470,7 +471,8 @@ private fun DayPage(
if (!isToday) return@LaunchedEffect
val now = slots.indexOfFirst { clock.isBefore(it.end) }
if (now <= 0) return@LaunchedEffect
val before = (if (places.isNotEmpty()) 1 else 0) + slots.take(now).sumOf { it.lessons.size }
val before = (if (places.isNotEmpty()) 1 else 0) + slots.take(now).sumOf { it.lessons.size } +
(1..now).count { i -> slots.getOrNull(i - 1)?.let { between(it, slots[i]) } != null }
list.scrollToItem(before)
}
ContentColumn {
@@ -487,7 +489,11 @@ private fun DayPage(
}
// "Занятия в Полоцке": the day's town before its lessons.
if (places.isNotEmpty()) item(key = "places") { AnimatedItem { DayPlaces(places) } }
slots.forEach { slot ->
slots.forEachIndexed { index, slot ->
// A window, another building or another town since the lesson before.
slots.getOrNull(index - 1)?.let { prev -> between(prev, slot) }?.let { b ->
item(key = "between|${slot.start}") { AnimatedItem { BetweenRow(b) } }
}
val running = isToday && !clock.isBefore(slot.start) && clock.isBefore(slot.end)
val status = when {
running -> "Идёт · ещё ${minutesLabel(Duration.between(clock, slot.end).toMinutes())}"
@@ -518,260 +524,6 @@ private fun DayPage(
}
}
/** A lesson as a soft card in its subject's colour, like School Planner's day list. */
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun LessonCard(
lesson: Lesson,
time: String?,
pair: Int? = null,
date: LocalDate? = null,
status: String? = null,
running: Boolean = false,
/** How far the lesson under way has gone, 0..1: a line runs round the card. */
progress: Float? = null,
onClick: (() -> Unit)?,
/** Just added or changed by a republished document: the card glows twice, then [onFreshShown]. */
fresh: Boolean = false,
onFreshShown: () -> Unit = {},
) {
val color = subjectColor(lesson.subject)
val show = LocalDisplay.current
val session = lesson.session
val content: @Composable () -> Unit = {
Column(Modifier.padding(horizontal = 20.dp, vertical = 16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
// Exams, credits and consultations announce themselves before the subject.
session?.let { SessionBadge(it, Modifier.padding(bottom = 2.dp)) }
Row(verticalAlignment = Alignment.Top) {
Text(lesson.subject, style = MaterialTheme.typography.titleMediumEmphasized, modifier = Modifier.weight(1f))
status?.let {
Spacer(Modifier.width(8.dp))
Surface(
shape = MaterialTheme.shapes.small,
color = if (running) color.onSoft else color.onSoft.copy(alpha = 0.12f),
contentColor = if (running) color.soft else color.onSoft,
) {
RollingText(it, style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp))
}
}
}
time?.let { TimeText(it + (pair?.takeIf { show.pairs }?.let { p -> " · $p пара" } ?: ""), style = MaterialTheme.typography.bodyLarge) }
FlowRow(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
if (lesson.distance) Meta(Icons.Rounded.LocationOn, "Дистанционно")
else withPlace(
lesson.rooms.takeIf { show.rooms && it.isNotEmpty() }?.let { it.joinToString(", ") + if (lesson.roomMoved) " (перенос)" else "" },
lesson.place,
)?.let { Meta(Icons.Rounded.LocationOn, it) }
if (show.teachers && lesson.teachers.isNotEmpty()) Meta(Icons.Rounded.Person, lesson.teachers.joinToString(", "))
lesson.subgroup?.let { Meta(Icons.Rounded.Groups, it) }
}
val tags = if (!show.tags) emptyList() else listOfNotNull(
lesson.type.ifEmpty { null }?.takeIf { session == null },
if (lesson.variant != "all") (if (lesson.variant == "upper") "белые недели" else "зелёные недели") else null,
if (lesson.groups.size > 1) "общее занятие" else null,
// German or French: a part of the group has this one, the rest the other.
if (lesson.option != null) "на выбор" else null,
when (lesson.audience) {
"foreign" -> "для иностранных студентов"
"local" -> "кроме иностранных студентов"
else -> null
},
)
val mark = date?.let { LocalMarks.current.forLesson(lesson.subject, it, lesson.start) }
if (tags.isNotEmpty() || lesson.uncertain || mark != null) FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.padding(top = 4.dp),
) {
mark?.let {
SoftTag(if (it.present) "был" else "пропуск", color, icon = if (it.present) Icons.Rounded.Check else Icons.Rounded.Close)
}
tags.forEach { SoftTag(it, color) }
if (lesson.uncertain) Tag("Сверьте недели", warning = true)
}
if (show.notes && lesson.condition.isNotEmpty()) Meta(Icons.Rounded.EventRepeat, "Пометка в Word: ${lesson.condition}")
if (date != null) NotesHint(LocalNotes.current.forLesson(lesson.subject, date, lesson.start), color.onSoft)
}
}
// A tapped card grows into its details page (Morph.kt).
val glow = remember { androidx.compose.animation.core.Animatable(0f) }
val reduced = rememberReducedMotion()
LaunchedEffect(fresh) {
if (!fresh) return@LaunchedEffect
if (!reduced) repeat(2) {
glow.animateTo(1f, tween(350, easing = EmphasizedDecelerate))
glow.animateTo(0f, tween(450))
}
onFreshShown()
}
// The fill itself turns tertiary and back, so the text stays as crisp as on any card.
val highlight = MaterialTheme.colorScheme.tertiaryContainer
val fill = if (glow.value > 0f) androidx.compose.ui.graphics.lerp(color.soft, highlight, glow.value) else color.soft
val origin = if (onClick != null) Modifier.morphOrigin("card|$date|${lesson.key}|${lesson.start}", color.soft, 16.dp) else Modifier
// The line round a running card grows a little every half minute, never jumps.
val shown by androidx.compose.animation.core.animateFloatAsState(progress ?: 0f, tween(1000), label = "progress")
val live = if (running && progress != null) Modifier.progressBorder(shown, color.onSoft, 16.dp) else Modifier
val border = when {
running && progress != null -> null
running -> BorderStroke(2.dp, color.onSoft)
session != null -> BorderStroke(2.dp, session.outline())
else -> null
}
if (onClick != null) Surface(
onClick = onClick,
shape = MaterialTheme.shapes.large,
color = fill,
contentColor = color.onSoft,
border = border,
modifier = Modifier.fillMaxWidth().then(origin).then(live),
content = content,
) else Surface(
shape = MaterialTheme.shapes.large,
color = color.soft,
contentColor = color.onSoft,
border = border,
modifier = Modifier.fillMaxWidth(),
content = content,
)
}
@Composable
private fun SoftTag(text: String, color: SubjectColor, icon: ImageVector? = null) {
Surface(shape = MaterialTheme.shapes.small, color = color.onSoft.copy(alpha = 0.12f), contentColor = color.onSoft) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = if (icon != null) 6.dp else 8.dp, end = 8.dp, top = 3.dp, bottom = 3.dp),
) {
icon?.let {
Icon(it, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(4.dp))
}
Text(text.replaceFirstChar { it.titlecase() }, style = MaterialTheme.typography.labelLarge)
}
}
}
@Composable
private fun Meta(icon: androidx.compose.ui.graphics.vector.ImageVector, text: String) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(icon, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(6.dp))
Text(text, style = MaterialTheme.typography.bodyMedium)
}
}
@Composable
private fun TypeTag(type: String) {
val scheme = MaterialTheme.colorScheme
val (bg, fg) = when (type) {
"Лекция" -> scheme.primaryContainer to scheme.onPrimaryContainer
"Практика" -> scheme.tertiaryContainer to scheme.onTertiaryContainer
"Экзамен", "Зачёт", "Защита курсовой работы" -> sessionColors().let { it.strong to it.onStrong }
"Консультация" -> sessionColors().let { it.soft to it.onSoft }
else -> scheme.secondaryContainer to scheme.onSecondaryContainer
}
Surface(shape = MaterialTheme.shapes.small, color = bg, contentColor = fg) {
Text(type.ifEmpty { "Занятие" }, style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp))
}
}
@Composable
private fun Tag(text: String, warning: Boolean = false) {
Surface(
shape = MaterialTheme.shapes.small,
color = if (warning) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.surfaceContainerHighest,
contentColor = if (warning) MaterialTheme.colorScheme.onErrorContainer else MaterialTheme.colorScheme.onSurfaceVariant,
) {
Text(text, style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp))
}
}
@Composable
internal fun LessonDetails(
lesson: Lesson,
bells: List<Bell>,
date: LocalDate? = null,
vm: MainViewModel? = null,
onClose: () -> Unit = {},
) {
val bell = bells.firstOrNull { it.start == lesson.start }
// Hiding closes the sheet, so its "Вернуть" goes to the screen; tasks deleted here, to the sheet.
val screenUndo = LocalUndo.current
UndoArea(bottom = false) {
Column(
Modifier
.verticalScroll(rememberScrollState())
.padding(start = 24.dp, end = 24.dp, bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
TypeTag(lesson.type)
Row(verticalAlignment = Alignment.CenterVertically) {
SubjectDot(lesson.subject, 16.dp)
Spacer(Modifier.width(14.dp))
Heading(lesson.subject, style = MaterialTheme.typography.headlineSmallEmphasized)
}
Detail(
Icons.Rounded.Schedule,
bell?.let { "${it.pair} пара" } ?: "Время",
lesson.start + (lesson.end?.let { " — $it" } ?: ""),
bell?.halves?.takeIf { it.size > 1 }?.joinToString(", ") { it.joinToString("–") },
)
Detail(Icons.Rounded.Person, "Преподаватель", lesson.teachers.joinToString(", ").ifEmpty { "Не указан" })
Detail(
Icons.Rounded.LocationOn,
"Аудитория",
if (lesson.distance) "Дистанционно" else lesson.rooms.joinToString(", ").ifEmpty { "Не указана" },
if (lesson.roomMoved) "В этот день — перенос аудитории" else null,
)
lesson.place?.let { Detail(Icons.Rounded.LocationCity, "Город", it, "Так указано в Word") }
Detail(Icons.Rounded.Groups, "Группы", lesson.groups.joinToString(", "), lesson.subgroup)
if (lesson.uncertain) Banner(
"Пометку «${lesson.condition}» не удалось сопоставить с таблицей недель. Сверьте с Word.",
offline = false,
onClose = null,
)
if (date != null && vm != null) {
LessonAttendance(vm, lesson, date)
LessonNotes(vm, lesson, date)
}
Heading("Исходная запись", style = MaterialTheme.typography.titleSmallEmphasized)
Surface(shape = MaterialTheme.shapes.large, color = MaterialTheme.colorScheme.surfaceContainerHigh) {
Text(lesson.raw, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(16.dp).fillMaxWidth())
}
if (vm != null) {
OutlinedButton(onClick = {
vm.setHidden(lesson.hideKey, true)
onClose()
screenUndo?.offer("Скрыто: «${lesson.subject}»", onUndo = { vm.setHidden(lesson.hideKey, false) })
}) {
Icon(Icons.Rounded.VisibilityOff, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Скрыть такие занятия")
}
Text(
"«${lesson.subject}»${lesson.type.takeIf { it.isNotEmpty() }?.let { " · ${it.lowercase()}" }.orEmpty()} пропадёт из расписания, " +
"обзора, виджета и напоминаний во все недели. Вернуть можно в настройках.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@Composable
private fun Detail(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, value: String, extra: String? = null) {
Row {
Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 2.dp))
Spacer(Modifier.width(16.dp))
Column {
Text(label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(value, style = MaterialTheme.typography.bodyLargeEmphasized)
extra?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) }
}
}
}
/** When the week table has no semester for this schedule: the Word rows by weekday. */
@Composable
private fun WordRows(t: Timetable, subgroup: String?, hidden: Set<String>, onOpen: (Lesson) -> Unit) {
@@ -795,140 +547,3 @@ private fun WordRows(t: Timetable, subgroup: String?, hidden: Set<String>, onOpe
}
}
}
@Composable
fun Banner(
text: String,
offline: Boolean,
onClose: (() -> Unit)?,
onRetry: (() -> Unit)? = null,
/** A way out the message asks for, such as "Выбрать группу". */
action: Pair<String, () -> Unit>? = null,
) {
Surface(
shape = MaterialTheme.shapes.large,
color = if (offline) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.errorContainer,
contentColor = if (offline) MaterialTheme.colorScheme.onSecondaryContainer else MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
) {
val buttons: @Composable () -> Unit = {
if (onRetry != null) TextButton(onClick = onRetry) { Text("Повторить") }
action?.let { (label, onClick) -> TextButton(onClick = onClick) { Text(label) } }
if (onClose != null) TextButton(onClick = onClose) { Text("OK") }
}
// With an action the message needs the whole width; the buttons go under it.
if (action != null) Column(Modifier.padding(start = 16.dp, end = 4.dp, top = 12.dp, bottom = 4.dp)) {
Row {
Icon(Icons.Rounded.ErrorOutline.takeIf { !offline } ?: Icons.Rounded.CloudOff, contentDescription = null)
Spacer(Modifier.width(12.dp))
Text(text, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f).padding(end = 12.dp))
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { buttons() }
} else Row(Modifier.padding(start = 16.dp, end = 4.dp, top = 8.dp, bottom = 8.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(if (offline) Icons.Rounded.CloudOff else Icons.Rounded.ErrorOutline, contentDescription = null)
Spacer(Modifier.width(12.dp))
Text(text, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f).padding(vertical = 8.dp))
buttons()
}
}
}
/**
* Centres an empty state in the space it is given — a phone screen or a tablet's pane —
* and scrolls when that space is too low, so the illustration is never squeezed.
*/
@Composable
fun CenteredEmpty(
modifier: Modifier = Modifier,
/** Room kept clear inside the scroll (under a FAB): content scrolls past it, never gets cut. */
bottomInset: Dp = 0.dp,
content: @Composable () -> Unit,
) {
BoxWithConstraints(modifier.fillMaxSize()) {
val height = maxHeight
Column(
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).heightIn(min = height).padding(bottom = bottomInset),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) { content() }
}
}
/**
* Empty state in the RideBus style: an icon on a seven-sided cookie shape, a title,
* a calm explanation and an optional tonal action.
*/
@Composable
fun Empty(
title: String,
text: String,
icon: ImageVector = Icons.Rounded.EventBusy,
action: Pair<String, () -> Unit>? = null,
) {
Column(
Modifier.fillMaxWidth().padding(vertical = 48.dp, horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(112.dp)
.background(MaterialTheme.colorScheme.primaryContainer, MaterialShapes.Cookie7Sided.toShape()),
) {
Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.onPrimaryContainer, modifier = Modifier.size(48.dp))
}
Spacer(Modifier.height(20.dp))
Text(title, style = MaterialTheme.typography.titleLargeEmphasized, textAlign = TextAlign.Center)
Spacer(Modifier.height(6.dp))
Text(
text,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
action?.let { (label, onClick) ->
Spacer(Modifier.height(20.dp))
FilledTonalButton(onClick = onClick) { Text(label) }
}
}
}
private val FACULTIES = mapOf(
"Финансово-экономический факультет" to "ФЭФ",
"Факультет информационных технологий" to "ФИТ",
"Факультет компьютерных наук и электроники" to "ФКНЭ",
"Гуманитарный факультет" to "ГФ",
"Инженерно-строительный факультет" to "ИСФ",
"Механико-технологический факультет" to "МТФ",
"Юридический факультет" to "ЮФ",
)
fun facultyShort(name: String): String? = name.takeIf { it.isNotEmpty() }?.let { FACULTIES[it] ?: it }
/**
* Opens a lesson's details as a page growing out of the card that was tapped (Morph.kt); from
* anywhere else (the week grid of a narrow column, a notification) it grows from the middle.
*/
@Composable
fun rememberLessonOpener(vm: MainViewModel): (Lesson, LocalDate?) -> Unit {
val morph = LocalMorph.current
return remember(morph, vm) {
{ lesson, date -> morph?.open(MorphKind.Page) { close -> LessonPage(lesson, date, vm, close) } }
}
}
/** The details as a page: a close button over them; full screen on a phone, a panel on a tablet. */
@Composable
private fun LessonPage(lesson: Lesson, date: LocalDate?, vm: MainViewModel, close: () -> Unit) {
val timetable by vm.timetable.collectAsStateWithLifecycle()
BoxWithConstraints(Modifier.fillMaxSize()) {
// The phone's page covers the bars; a tablet's panel sits inside them.
val insets = if (maxWidth >= 600.dp) Modifier else Modifier.windowInsetsPadding(WindowInsets.systemBars)
Column(Modifier.fillMaxSize().then(insets)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(start = 8.dp, top = 4.dp)) {
IconButton(onClick = close) { Icon(Icons.Rounded.Close, contentDescription = "Закрыть") }
}
LessonDetails(lesson, timetable?.bells.orEmpty(), date, vm, onClose = close)
}
}
}

View File

@@ -1,4 +1,4 @@
package org.xtimms.psu.schedule.ui
package org.xtimms.psu.schedule.ui.schedule
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
@@ -49,6 +49,19 @@ import org.xtimms.psu.schedule.data.weekdayName
import java.time.Duration
import java.time.LocalDate
import java.time.LocalTime
import org.xtimms.psu.schedule.ui.components.Heading
import org.xtimms.psu.schedule.ui.components.TimeText
import org.xtimms.psu.schedule.ui.components.WeekMark
import org.xtimms.psu.schedule.ui.home.CompactLesson
import org.xtimms.psu.schedule.ui.lesson.LocalDisplay
import org.xtimms.psu.schedule.ui.lesson.SessionBadge
import org.xtimms.psu.schedule.ui.lesson.Slot
import org.xtimms.psu.schedule.ui.lesson.SubjectDot
import org.xtimms.psu.schedule.ui.lesson.session
import org.xtimms.psu.schedule.ui.lesson.subjectColor
import org.xtimms.psu.schedule.ui.motion.RollingText
import org.xtimms.psu.schedule.ui.motion.morphOrigin
import org.xtimms.psu.schedule.ui.motion.morphContent
// The schedule of a foldable half opened on a table, as a desk stand: above the fold, facing the
// student, one lesson large enough to read from a distance; below it, under the thumb, the bar,
@@ -133,7 +146,7 @@ private fun StandLesson(slot: Slot, clock: LocalTime, isToday: Boolean, onOpen:
contentColor = color.onSoft,
modifier = modifier.morphOrigin("stand|${slot.date}|${lesson.key}|${lesson.start}", color.soft, 28.dp),
) {
Column(Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Column(Modifier.morphContent("stand|${slot.date}|${lesson.key}|${lesson.start}").padding(24.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
TimeText(
"${slot.start} → ${slot.end}" + (lesson.pair?.let { " · $it пара" } ?: ""),

View File

@@ -1,6 +1,6 @@
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
package org.xtimms.psu.schedule.ui
package org.xtimms.psu.schedule.ui.schedule
import androidx.compose.runtime.getValue
import androidx.compose.animation.core.tween
@@ -50,6 +50,27 @@ import org.xtimms.psu.schedule.data.weekdayName
import org.xtimms.psu.schedule.data.withPlace
import java.time.LocalDate
import java.time.LocalTime
import org.xtimms.psu.schedule.ui.MainViewModel
import org.xtimms.psu.schedule.ui.components.navBarPadding
import org.xtimms.psu.schedule.ui.lesson.DayPlaceMark
import org.xtimms.psu.schedule.ui.lesson.LocalDisplay
import org.xtimms.psu.schedule.ui.lesson.SessionDot
import org.xtimms.psu.schedule.ui.lesson.Slot
import org.xtimms.psu.schedule.ui.lesson.abbreviate
import org.xtimms.psu.schedule.ui.lesson.outline
import org.xtimms.psu.schedule.ui.lesson.placesOn
import org.xtimms.psu.schedule.ui.lesson.rememberClock
import org.xtimms.psu.schedule.ui.lesson.session
import org.xtimms.psu.schedule.ui.lesson.slotsOn
import org.xtimms.psu.schedule.ui.lesson.subjectColor
import org.xtimms.psu.schedule.ui.motion.morphContent
import org.xtimms.psu.schedule.ui.motion.morphOrigin
import org.xtimms.psu.schedule.ui.notes.LocalNotes
import org.xtimms.psu.schedule.ui.lesson.forLesson
import org.xtimms.psu.schedule.ui.notes.forLesson
import org.xtimms.psu.schedule.ui.notes.open
import androidx.compose.ui.draw.clip
import org.xtimms.psu.schedule.ui.lesson.lessonPress
private val HOUR = 60.dp
private val GUTTER = 36.dp
@@ -182,7 +203,8 @@ fun WeekGrid(
private fun LessonBlock(lesson: Lesson, slot: Slot, wide: Boolean, notes: Int, onClick: () -> Unit, modifier: Modifier) {
val color = subjectColor(lesson.subject)
val session = lesson.session
val origin = Modifier.morphOrigin("grid|${slot.date}|${lesson.key}|${lesson.start}", color.container, 8.dp)
val morphKey = "grid|${slot.date}|${lesson.key}|${lesson.start}"
val origin = Modifier.morphOrigin(morphKey, color.container, 8.dp)
val rooms = lesson.rooms.joinToString(", ").takeIf { LocalDisplay.current.rooms }.orEmpty()
// A narrow column keeps the room whole: the day's town is under its date.
val room = when {
@@ -191,17 +213,16 @@ private fun LessonBlock(lesson: Lesson, slot: Slot, wide: Boolean, notes: Int, o
else -> rooms.ifEmpty { lesson.place.orEmpty() }
}
Surface(
onClick = onClick,
shape = MaterialTheme.shapes.small,
color = color.container,
contentColor = color.content,
border = session?.let { androidx.compose.foundation.BorderStroke(2.dp, it.outline()) },
modifier = modifier.then(origin).semantics {
modifier = modifier.then(origin).clip(MaterialTheme.shapes.small).lessonPress(lesson, slot.date, onClick).semantics {
contentDescription = "${lesson.subject}, ${slot.start}–${slot.end}" +
(if (room.isNotEmpty()) ", ауд. $room" else "") + (lesson.type.takeIf { it.isNotEmpty() }?.let { ", $it" } ?: "") +
(if (notes > 0) ", заданий и заметок: $notes" else "")
},
) {
) { Box(Modifier.fillMaxSize().morphContent(morphKey)) {
// A small dot in the corner marks lessons with open tasks or notes.
if (notes > 0) Box(Modifier.fillMaxSize().padding(5.dp), contentAlignment = Alignment.TopEnd) {
Box(Modifier.size(8.dp).background(color.content, CircleShape))
@@ -217,5 +238,5 @@ private fun LessonBlock(lesson: Lesson, slot: Slot, wide: Boolean, notes: Int, o
)
if (room.isNotEmpty()) Text(room, style = MaterialTheme.typography.labelSmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
} }
}

View File

@@ -1,6 +1,6 @@
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
package org.xtimms.psu.schedule.ui
package org.xtimms.psu.schedule.ui.search
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.ui.graphics.graphicsLayer
@@ -75,18 +75,77 @@ import org.xtimms.psu.schedule.data.Lesson
import org.xtimms.psu.schedule.data.monday
import org.xtimms.psu.schedule.data.today
import java.time.LocalDate
import org.xtimms.psu.schedule.ui.AppNav
import org.xtimms.psu.schedule.ui.MainViewModel
import org.xtimms.psu.schedule.ui.MenuButton
import org.xtimms.psu.schedule.ui.components.Banner
import org.xtimms.psu.schedule.ui.components.CardRow
import org.xtimms.psu.schedule.ui.components.CenteredEmpty
import org.xtimms.psu.schedule.ui.components.ContentColumn
import org.xtimms.psu.schedule.ui.components.DayStrip
import org.xtimms.psu.schedule.ui.components.DetailPane
import org.xtimms.psu.schedule.ui.components.Empty
import org.xtimms.psu.schedule.ui.components.Heading
import org.xtimms.psu.schedule.ui.components.LessonsSkeleton
import org.xtimms.psu.schedule.ui.components.ListCard
import org.xtimms.psu.schedule.ui.components.MAX_CONTENT
import org.xtimms.psu.schedule.ui.components.PaneRow
import org.xtimms.psu.schedule.ui.components.RowDivider
import org.xtimms.psu.schedule.ui.components.SearchField
import org.xtimms.psu.schedule.ui.components.WeekMark
import org.xtimms.psu.schedule.ui.components.avatarLabel
import org.xtimms.psu.schedule.ui.components.isTablet
import org.xtimms.psu.schedule.ui.components.navBarPadding
import org.xtimms.psu.schedule.ui.components.twoPane
import org.xtimms.psu.schedule.ui.components.weekTint
import org.xtimms.psu.schedule.ui.components.weekTintOf
import org.xtimms.psu.schedule.ui.groups.facultyShort
import org.xtimms.psu.schedule.ui.lesson.LessonCard
import org.xtimms.psu.schedule.ui.lesson.subjectColor
import org.xtimms.psu.schedule.ui.motion.AnimatedItem
import org.xtimms.psu.schedule.ui.schedule.weekTitle
import androidx.compose.material.icons.rounded.Star
import androidx.compose.material.icons.rounded.StarBorder
import androidx.compose.material.icons.rounded.History
import androidx.compose.material3.LocalContentColor
import androidx.compose.ui.graphics.vector.ImageVector
import org.xtimms.psu.schedule.data.minutesLabel
import org.xtimms.psu.schedule.data.parseTime
import org.xtimms.psu.schedule.ui.lesson.rememberClock
import java.time.Duration
private sealed interface Target {
data class Teacher(val name: String) : Target
data class Room(val room: String) : Target
}
/** How a teacher or a room is kept among the favorites and the recent ones: "teacher:Ковшик О.С.". */
private val Target.key: String
get() = when (this) {
is Target.Teacher -> "teacher:$name"
is Target.Room -> "room:$room"
}
private fun targetOf(key: String): Target? = when {
key.startsWith("teacher:") -> Target.Teacher(key.removePrefix("teacher:"))
key.startsWith("room:") -> Target.Room(key.removePrefix("room:"))
else -> null
}
private val Target.title: String
get() = when (this) {
is Target.Teacher -> name
is Target.Room -> "Аудитория $room"
}
internal fun startDate(): LocalDate = if (today().dayOfWeek.value == 7) today().plusDays(1) else today()
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class, ExperimentalLayoutApi::class)
@Composable
fun SearchScreen(vm: MainViewModel, nav: AppNav) {
val timetable by vm.timetable.collectAsStateWithLifecycle()
val settings by vm.settings.collectAsStateWithLifecycle()
val favorites = settings?.searchFavorites.orEmpty()
var query by rememberSaveable { mutableStateOf("") }
var target by remember { mutableStateOf<Target?>(null) }
var date by remember { mutableStateOf(startDate()) }
@@ -106,6 +165,8 @@ fun SearchScreen(vm: MainViewModel, nav: AppNav) {
delay(250)
attempt { vm.api.searchDirectory(query.trim()) }.onSuccess { hits = it; error = null }.onFailure { error = it.message }
}
// Whoever is opened goes to the top of "Недавние".
LaunchedEffect(target) { target?.let { vm.rememberSearch(it.key) } }
LaunchedEffect(target, date, retries) {
val t = target ?: return@LaunchedEffect
loading = true
@@ -180,6 +241,7 @@ fun SearchScreen(vm: MainViewModel, nav: AppNav) {
if (dated && date != startDate()) IconButton(onClick = { date = startDate() }) {
Icon(Icons.Rounded.Today, contentDescription = "Сегодня")
}
target?.let { t -> FavoriteButton(t.key in favorites) { vm.toggleSearchFavorite(t.key) } }
},
)
}
@@ -195,13 +257,26 @@ fun SearchScreen(vm: MainViewModel, nav: AppNav) {
if (target == null || pane) {
item { SearchField(query, { query = it }, "Фамилия или номер аудитории") }
val h = hits
if (h == null && !pane) item {
if (h == null) {
// Before a query: the starred ones and the latest, a tap from today's day.
val starred = favorites.mapNotNull(::targetOf)
val recent = settings?.searchRecent.orEmpty().filter { it !in favorites }.mapNotNull(::targetOf)
val open: (Target) -> Unit = { target = it; date = startDate() }
if (starred.isNotEmpty()) item(key = "starred") {
SavedTargets(Icons.Rounded.Star, "Избранное", starred, favorites, open, vm::toggleSearchFavorite)
}
if (recent.isNotEmpty()) item(key = "recent") {
SavedTargets(Icons.Rounded.History, "Недавние", recent, favorites, open, vm::toggleSearchFavorite)
}
if (starred.isEmpty() && recent.isEmpty() && !pane) item {
Box(Modifier.fillParentMaxHeight(0.75f).fillMaxWidth(), contentAlignment = Alignment.Center) { Empty(
"Кто где",
"Найдите преподавателя или аудиторию — покажем занятия на выбранный день по всем расписаниям университета.",
"Найдите преподавателя или аудиторию — покажем занятия на выбранный день по всем расписаниям университета. " +
"Звёздочкой их можно добавить в избранное.",
icon = Icons.Rounded.PersonSearch,
) }
} else if (h != null) {
}
} else {
if (h.teachers.isNotEmpty()) item {
ListCard(Icons.Rounded.Person, "Преподаватели") {
h.teachers.forEachIndexed { index, t ->
@@ -235,6 +310,7 @@ fun SearchScreen(vm: MainViewModel, nav: AppNav) {
}
}
val dayList: @Composable (Modifier) -> Unit = { modifier ->
val clock = rememberClock()
LazyColumn(
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 24.dp + navBarPadding()),
verticalArrangement = Arrangement.spacedBy(12.dp),
@@ -250,6 +326,12 @@ fun SearchScreen(vm: MainViewModel, nav: AppNav) {
}
}
items(d.lessons, key = { "${it.start}|${it.subject}|${it.rooms}" }) { l -> AnimatedItem {
// Today: where the teacher is now and where next, as on the student's own day.
val start = parseTime(l.start)
val end = l.end?.let(::parseTime) ?: start.plusMinutes(85)
val now = date == today()
val running = now && !clock.isBefore(start) && clock.isBefore(end)
val soon = now && clock.isBefore(start) && Duration.between(clock, start).toMinutes() <= 120
LessonCard(
Lesson(
key = l.start + l.subject,
@@ -269,6 +351,13 @@ fun SearchScreen(vm: MainViewModel, nav: AppNav) {
),
time = l.start + (l.end?.let { " → $it" } ?: ""),
pair = bells.firstOrNull { it.start == l.start }?.pair,
status = when {
running -> "Идёт · ещё ${minutesLabel(Duration.between(clock, end).toMinutes())}"
soon -> "Через ${minutesLabel(Duration.between(clock, start).toMinutes())}"
else -> null
},
running = running,
progress = if (running) Duration.between(start, clock).toSeconds().toFloat() / Duration.between(start, end).toSeconds().coerceAtLeast(1) else null,
onClick = null,
) }
}
@@ -323,6 +412,7 @@ fun SearchScreen(vm: MainViewModel, nav: AppNav) {
if (date != startDate()) IconButton(onClick = { date = startDate() }) {
Icon(Icons.Rounded.Today, contentDescription = "Сегодня")
}
FavoriteButton(t.key in favorites) { vm.toggleSearchFavorite(t.key) }
IconButton(onClick = { target = null; day = null }) {
Icon(Icons.Rounded.Close, contentDescription = "Закрыть")
}
@@ -411,6 +501,49 @@ private const val SPAN = 52
@Composable
private fun SectionTitle(text: String) = Heading(text)
/** The star of a teacher or a room: filled while it is among the favorites. */
@Composable
private fun FavoriteButton(starred: Boolean, onToggle: () -> Unit) {
IconButton(onClick = onToggle) {
Icon(
if (starred) Icons.Rounded.Star else Icons.Rounded.StarBorder,
contentDescription = if (starred) "Убрать из избранного" else "В избранное",
tint = if (starred) MaterialTheme.colorScheme.primary else LocalContentColor.current,
)
}
}
/** "Избранное" or "Недавние": a row per teacher or room, each with its star. */
@Composable
private fun SavedTargets(
icon: ImageVector,
title: String,
targets: List<Target>,
favorites: List<String>,
onOpen: (Target) -> Unit,
onStar: (String) -> Unit,
) {
ListCard(icon, title) {
targets.forEachIndexed { index, t ->
if (index > 0) RowDivider()
CardRow(
avatar = when (t) {
is Target.Teacher -> avatarLabel(t.name)
is Target.Room -> t.room.take(4)
},
color = subjectColor(t.title),
title = t.title,
subtitle = when (t) {
is Target.Teacher -> "Преподаватель · занятия сегодня"
is Target.Room -> "Занятия сегодня"
},
onClick = { onOpen(t) },
trailing = { FavoriteButton(t.key in favorites) { onStar(t.key) } },
)
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun RoomChips(rooms: List<String>, onClick: (String) -> Unit) {

View File

@@ -1,6 +1,6 @@
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
package org.xtimms.psu.schedule.ui
package org.xtimms.psu.schedule.ui.settings
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
@@ -48,6 +48,21 @@ import org.xtimms.psu.schedule.data.ThemeMode
import org.xtimms.psu.schedule.data.OPTION_PREFIX
import org.xtimms.psu.schedule.data.AUDIENCE_PREFIX
import org.xtimms.psu.schedule.notify.Reminders
import org.xtimms.psu.schedule.ui.MainViewModel
import org.xtimms.psu.schedule.ui.components.ConnectedChoice
import org.xtimms.psu.schedule.ui.components.ContentColumn
import org.xtimms.psu.schedule.ui.components.Heading
import org.xtimms.psu.schedule.ui.components.appBarColors
import org.xtimms.psu.schedule.ui.components.navBarPadding
import org.xtimms.psu.schedule.ui.home.ChangesSheet
import org.xtimms.psu.schedule.ui.home.changeDate
import org.xtimms.psu.schedule.ui.home.changesLabel
import org.xtimms.psu.schedule.ui.theme.LocalThemeReveal
import org.xtimms.psu.schedule.ui.theme.TapPoint
import org.xtimms.psu.schedule.ui.theme.rememberTapPoint
import org.xtimms.psu.schedule.ui.theme.tapPoint
import androidx.compose.runtime.rememberCoroutineScope
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -56,6 +71,14 @@ fun SettingsScreen(vm: MainViewModel, onBack: () -> Unit) {
val timetable by vm.timetable.collectAsStateWithLifecycle()
val changes by vm.changes.collectAsStateWithLifecycle()
var changesOpen by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(false) }
// A new theme spreads from the option or the switch that was tapped.
val reveal = LocalThemeReveal.current
val themeTap = rememberTapPoint()
val colorsTap = rememberTapPoint()
val scope = rememberCoroutineScope()
fun reveal(tap: TapPoint, apply: () -> Unit) {
scope.launch { reveal?.change(tap.at, apply) ?: apply() }
}
val s = settings ?: return
val context = LocalContext.current
val permission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
@@ -100,15 +123,15 @@ fun SettingsScreen(vm: MainViewModel, onBack: () -> Unit) {
ConnectedChoice(
options = listOf(ThemeMode.System to "Авто", ThemeMode.Light to "Светлая", ThemeMode.Dark to "Тёмная"),
selected = s.theme,
onSelect = vm::setTheme,
modifier = Modifier.fillMaxWidth(),
onSelect = { mode -> if (mode != s.theme) reveal(themeTap) { vm.setTheme(mode) } },
modifier = Modifier.fillMaxWidth().tapPoint(themeTap),
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) SwitchRow(
"Цвета обоев",
"Цвета приложения подстраиваются под обои телефона",
s.dynamicColor,
vm::setDynamicColor,
)
Modifier.tapPoint(colorsTap),
) { on -> reveal(colorsTap) { vm.setDynamicColor(on) } }
}
Section("Расписание") {
val d = s.display
@@ -196,7 +219,7 @@ fun SettingsScreen(vm: MainViewModel, onBack: () -> Unit) {
"Занятия в другом городе",
"Вечером накануне — «Завтра занятия в Полоцке» с первой парой и аудиторией. Если включены напоминания, о первой паре такого дня напомнит раньше: нужно время на дорогу.",
s.travelAlerts && allowed,
notifying(vm::setTravelAlerts),
change = notifying(vm::setTravelAlerts),
)
if (s.travelAlerts && allowed) {
Text("Вечером в", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
@@ -222,7 +245,7 @@ fun SettingsScreen(vm: MainViewModel, onBack: () -> Unit) {
"Переносы аудиторий",
"Утром в день переноса — какая пара и в какой аудитории, вместо обычной.",
s.roomAlerts && allowed,
notifying(vm::setRoomAlerts),
change = notifying(vm::setRoomAlerts),
)
if (s.roomAlerts && allowed) {
Text("Утром в", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
@@ -238,7 +261,7 @@ fun SettingsScreen(vm: MainViewModel, onBack: () -> Unit) {
"Текущая пара в шторке",
"Пока идёт пара — уведомление с оставшимся временем и следующей парой. На Android 16 оно видно в строке состояния и на экране блокировки.",
s.liveLesson && allowed,
notifying(vm::setLiveLesson),
change = notifying(vm::setLiveLesson),
)
}
Section("О расписании") {
@@ -290,7 +313,7 @@ private fun Section(title: String, content: @Composable () -> Unit) {
}
@Composable
private fun SwitchRow(title: String, text: String, checked: Boolean, change: (Boolean) -> Unit) {
private fun SwitchRow(title: String, text: String, checked: Boolean, modifier: Modifier = Modifier, change: (Boolean) -> Unit) {
val haptic = LocalHapticFeedback.current
val onChange: (Boolean) -> Unit = { on ->
haptic.performHapticFeedback(if (on) HapticFeedbackType.ToggleOn else HapticFeedbackType.ToggleOff)
@@ -300,7 +323,7 @@ private fun SwitchRow(title: String, text: String, checked: Boolean, change: (Bo
Row(
verticalAlignment = Alignment.CenterVertically,
// The press highlight follows the row's rounded shape instead of a hard rectangle.
modifier = Modifier.fillMaxWidth().clip(MaterialTheme.shapes.medium).toggleable(value = checked, role = Role.Switch, onValueChange = onChange),
modifier = modifier.fillMaxWidth().clip(MaterialTheme.shapes.medium).toggleable(value = checked, role = Role.Switch, onValueChange = onChange),
) {
Column(Modifier.weight(1f).padding(end = 16.dp)) {
Text(title, style = MaterialTheme.typography.bodyLarge)

View File

@@ -1,6 +1,6 @@
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
package org.xtimms.psu.schedule.ui
package org.xtimms.psu.schedule.ui.subjects
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.staggeredgrid.items
@@ -72,6 +72,34 @@ import org.xtimms.psu.schedule.data.shortWeekday
import org.xtimms.psu.schedule.data.today
import org.xtimms.psu.schedule.data.weekdayName
import java.time.LocalTime
import org.xtimms.psu.schedule.ui.AppNav
import org.xtimms.psu.schedule.ui.MainViewModel
import org.xtimms.psu.schedule.ui.MenuButton
import org.xtimms.psu.schedule.ui.components.CenteredEmpty
import org.xtimms.psu.schedule.ui.components.Empty
import org.xtimms.psu.schedule.ui.components.Heading
import org.xtimms.psu.schedule.ui.components.LocalUndo
import org.xtimms.psu.schedule.ui.components.SubjectsSkeleton
import org.xtimms.psu.schedule.ui.components.TimeText
import org.xtimms.psu.schedule.ui.components.UndoArea
import org.xtimms.psu.schedule.ui.components.appBarColors
import org.xtimms.psu.schedule.ui.components.navBarPadding
import org.xtimms.psu.schedule.ui.home.SectionCard
import org.xtimms.psu.schedule.ui.lesson.LocalDisplay
import org.xtimms.psu.schedule.ui.lesson.LocalMarks
import org.xtimms.psu.schedule.ui.lesson.Slot
import org.xtimms.psu.schedule.ui.lesson.SubjectDot
import org.xtimms.psu.schedule.ui.lesson.abbreviate
import org.xtimms.psu.schedule.ui.lesson.rememberClock
import org.xtimms.psu.schedule.ui.lesson.slotsOn
import org.xtimms.psu.schedule.ui.lesson.subjectColor
import org.xtimms.psu.schedule.ui.lesson.subjectKey
import org.xtimms.psu.schedule.ui.motion.AnimatedItem
import org.xtimms.psu.schedule.ui.notes.LocalNotes
import org.xtimms.psu.schedule.ui.notes.NoteEditor
import org.xtimms.psu.schedule.ui.notes.NoteRow
import org.xtimms.psu.schedule.ui.notes.NoteTarget
import org.xtimms.psu.schedule.ui.lesson.statsFor
/** Everything the loaded weeks say about one subject. */
private class SubjectSummary(
@@ -406,7 +434,7 @@ private fun SubjectNotes(s: SubjectSummary, vm: MainViewModel) {
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
notes.forEach { note -> NoteRow(note, onToggle = { vm.toggleNote(note.id) }, onClick = { editing = note }, showSubject = false) }
notes.forEach { note -> NoteRow(note, onToggle = { vm.toggleNote(note.id) }, onClick = { editing = note }, showSubject = false, onPostpone = { vm.postponeNote(note, it) }) }
if (next != null) FilledTonalButton(onClick = { adding = true }) {
Icon(Icons.Rounded.Add, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))

View File

@@ -0,0 +1,141 @@
package org.xtimms.psu.schedule.ui.theme
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.ClipOp
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.clipPath
import androidx.compose.ui.graphics.layer.GraphicsLayer
import androidx.compose.ui.graphics.layer.drawLayer
import androidx.compose.ui.graphics.rememberGraphicsLayer
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.onGloballyPositioned
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeoutOrNull
import org.xtimms.psu.schedule.ui.motion.Emphasized
import org.xtimms.psu.schedule.ui.motion.rememberReducedMotion
import kotlin.math.hypot
// Switching between light and dark: the new theme spreads in a circle from the finger, as if the
// old screen were wiped away. A picture of the old screen covers the app while the theme changes
// under it, and a growing hole in that picture shows the new one.
@Stable
class ThemeReveal internal constructor(private val layer: GraphicsLayer, private val reduced: () -> Boolean) {
internal var picture by mutableStateOf<ImageBitmap?>(null)
internal var center = Offset.Zero
internal val radius = Animatable(0f)
internal var key: Any? by mutableStateOf(null)
/**
* Applies a theme change with [apply], revealing it from [at] (a point in the window). Without
* a point, or with animations turned off in the system, the theme just changes.
*/
suspend fun change(at: Offset?, apply: () -> Unit) {
if (at == null || reduced() || picture != null) return apply()
picture = layer.toImageBitmap()
center = at
radius.snapTo(0f)
val before = key
apply()
// The settings are saved first; the new theme comes a moment later. "Авто" may change nothing.
val changed = withTimeoutOrNull(600) { snapshotFlow { key }.first { it != before } } != null
if (changed) {
// Let the new theme draw once under the picture before the hole opens.
withFrameNanos {}
withFrameNanos {}
val p = picture ?: return
val far = listOf(Offset.Zero, Offset(p.width.toFloat(), 0f), Offset(0f, p.height.toFloat()), Offset(p.width.toFloat(), p.height.toFloat()))
.maxOf { hypot(it.x - at.x, it.y - at.y) }
radius.animateTo(far, tween(550, easing = Emphasized))
}
picture = null
}
}
val LocalThemeReveal = staticCompositionLocalOf<ThemeReveal?> { null }
/**
* The app under a theme reveal. [key] is the theme as drawn (dark or light, wallpaper colours):
* when it changes, the reveal knows the new theme is in place.
*/
@Composable
fun ThemeRevealHost(key: Any, content: @Composable () -> Unit) {
val layer = rememberGraphicsLayer()
val reduced = rememberReducedMotion()
val reveal = remember(layer, reduced) { ThemeReveal(layer) { reduced } }
SideEffect { reveal.key = key }
Box(Modifier.fillMaxSize()) {
Box(
Modifier
.fillMaxSize()
.drawWithContent {
layer.record { this@drawWithContent.drawContent() }
drawLayer(layer)
},
) {
CompositionLocalProvider(LocalThemeReveal provides reveal) { content() }
}
reveal.picture?.let { picture ->
Canvas(
Modifier
.fillMaxSize()
// The old screen is only a picture: taps wait for the new one.
.pointerInput(Unit) { awaitEachGesture { awaitFirstDown(pass = PointerEventPass.Initial).consume() } },
) {
val hole = Path().apply {
addOval(androidx.compose.ui.geometry.Rect(reveal.center, reveal.radius.value))
}
clipPath(hole, ClipOp.Difference) { drawImage(picture) }
}
}
}
}
/** Where the finger last went down on an element, in the window: the centre of a theme reveal. */
class TapPoint {
var at: Offset? = null
private set
private var coordinates: LayoutCoordinates? = null
internal fun place(c: LayoutCoordinates) {
coordinates = c
}
internal fun down(local: Offset) {
at = coordinates?.takeIf { it.isAttached }?.localToRoot(local)
}
}
@Composable
fun rememberTapPoint(): TapPoint = remember { TapPoint() }
fun Modifier.tapPoint(point: TapPoint): Modifier = this
.onGloballyPositioned(point::place)
.pointerInput(point) {
awaitEachGesture {
point.down(awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial).position)
}
}

View File

@@ -43,7 +43,7 @@ import org.xtimms.psu.schedule.data.monday
import org.xtimms.psu.schedule.data.plural
import org.xtimms.psu.schedule.data.shortWeekday
import org.xtimms.psu.schedule.data.today
import org.xtimms.psu.schedule.ui.abbreviate
import org.xtimms.psu.schedule.ui.lesson.abbreviate
import java.time.LocalDate
/** A day of the week as the widget shows it. */

View File

@@ -27,7 +27,7 @@ export const lessonLine = (l) =>
// "Занятия в Полоцке": a day of a group or subgroup in another town, not a lesson.
const placeLine = (p) =>
[
`@ ${p.date || DAYS[p.day]}`,
`@ ${p.date || DAYS[p.day]}${p.from ? ` с ${p.from}` : ""}`,
p.text,
[...p.groups, ...p.subgroups.map((s) => `${s.group}/${s.label.replace("Подгруппа ", "п")}`)].join(","),
].join(" | ");

View File

@@ -5,7 +5,8 @@ import { roomMoves } from "./rooms.js";
// whole text as the subject. These checks find such cells after parsing, so a new format or a
// typo in Word shows up as soon as the document is published instead of when a student notices.
const MONTHS = /(?<![а-яё])(?:январ|феврал|март|апрел|ма[йя]|июн|июл|август|сентябр|октябр|ноябр|декабр)[а-яё]*/iu;
// "мая" is a month, "Майнор" is not.
const MONTHS = /(?<![а-яё])(?:(?:январ|феврал|март|апрел|июн|июл|август|сентябр|октябр|ноябр|декабр)[а-яё]*|ма[йя](?![а-яё]))/iu;
// The longest semester has about twenty weeks; "27н." is a typo, not a week.
const LAST_WEEK = 24;

View File

@@ -10,7 +10,7 @@ import { parseWeekRanges, parseTextDates, DATE_LIST, weekday } from "../shared/w
import { roomMoves, onlyPlace, weekPlan, partsByWeek } from "./rooms.js";
import { placeNote, lessonPlace, placeOf } from "./places.js";
const run = promisify(execFile);
export const PARSER_VERSION = 18;
export const PARSER_VERSION = 19;
const days = [
"понедельник",
"вторник",
@@ -20,10 +20,17 @@ const days = [
"суббота",
"воскресенье",
];
const clean = (value) =>
export const clean = (value) =>
value
.replace(/\u00a0/g, " ")
// "Ф и з и ч е с к а я к у л ь т у р а": letters spaced out for show, words apart by
// several spaces. Joined back before the spaces collapse: "Физическая культура".
.replace(/(?<![\p{L}\d])(?:\p{L} ){3,}\p{L}(?![\p{L}\d])/gu, (word) => word.replace(/ /g, ""))
// "со 2 по 14 нед.": the usual "2-14 нед.".
.replace(/(?<![\p{L}\d])(?:с|со)\s*(\d{1,2})\s*по\s*(\d{1,2})(?=\s*н(?:ед)?\.?)/giu, "$1-$2")
.replace(/[\t ]+/g, " ")
.replace(/«\s+/g, "«")
.replace(/\s+»/g, "»")
.replace(/\s*\n\s*/g, "\n")
.trim();
// A group is a two-digit year and a capitalised abbreviation: "23-БА", "24-ДОи", "23-ИНС-1",
@@ -595,7 +602,9 @@ export function parseDocx(buffer, source = {}) {
date = null,
time = null,
pair = null,
timeCol = null;
timeCol = null,
// The day's first lesson time: a town note further down holds from its own row on.
dayStart = null;
// Session schedules use separate room, subject and teacher columns.
const session = rows[header].some((cell) => /пара/i.test(cell.text));
let sessionColumns = null;
@@ -625,6 +634,7 @@ export function parseDocx(buffer, source = {}) {
: null;
time = null;
pair = null;
dayStart = null;
}
if (!day) continue;
// Credits and exams give only the start ("8.30"); the bell table supplies the end.
@@ -637,6 +647,7 @@ export function parseDocx(buffer, source = {}) {
pair = session ? row.find((c) => c.col === 1)?.text : null;
}
if (!time) continue;
dayStart ??= time.start;
seenRows++;
const continuedTime =
row.find((c) => c.col === timeCol)?.merge === "continue";
@@ -667,7 +678,9 @@ export function parseDocx(buffer, source = {}) {
// are in that town. A lesson may follow it in the same cell.
const note = placeNote(raw);
if (note) {
places.push({ day, date, groups: targetGroups, subgroups: columnSubgroups, place: note.place, text: note.text });
// "Занятия в Полоцке" in the 13:40 row of 25-ИТ: the morning is in Novopolotsk.
const from = time.start > dayStart ? time.start : null;
places.push({ day, date, groups: targetGroups, subgroups: columnSubgroups, place: note.place, text: note.text, ...(from && { from }) });
raw = note.rest;
if (!raw.trim()) return;
}

View File

@@ -64,7 +64,12 @@ export function placeOf(lesson, places, labelsOf) {
let found = null;
for (const group of lesson.groups) {
const notes = places.filter(
(p) => p.day === lesson.day && (!p.date || p.date === lesson.date) && p.groups.includes(group),
(p) =>
p.day === lesson.day &&
(!p.date || p.date === lesson.date) &&
p.groups.includes(group) &&
// A note from the middle of the day holds from its row on.
(!p.from || lesson.start >= p.from),
);
if (!notes.length) return null;
const label = lesson.subgroups.find((s) => s.group === group)?.label;

View File

@@ -9,7 +9,13 @@
export function placesOn(places, group, labels, day, date = null) {
const on = (places || [])
.filter((p) => p.groups.includes(group) && p.day === day && (!p.date || p.date === date))
.map((p) => ({ place: p.place, text: p.text, subgroup: p.subgroups.find((s) => s.group === group)?.label || null }));
.map((p) => ({
place: p.place,
text: p.text,
subgroup: p.subgroups.find((s) => s.group === group)?.label || null,
// "13:40": the note holds from that lesson on, not the whole day.
...(p.from && { from: p.from }),
}));
const whole = on.filter((p) => !p.subgroup);
for (const p of on)
if (

View File

@@ -1,6 +1,7 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { groupsIn, alternatives, entries, details, leadingTime, parallel, audienceOf } from "../server/parser.js";
import { groupsIn, alternatives, entries, details, leadingTime, parallel, audienceOf, clean } from "../server/parser.js";
import { placeOf } from "../server/places.js";
import { roomMoves, weekPlan, onlyPlace } from "../server/rooms.js";
import { peopleAndRooms, roomsOn, teachersOn, movedOn } from "../server/directory.js";
@@ -161,6 +162,20 @@ test("Вид занятия после преподавателя: фамили
assert.equal(details("(л.з.) Алексеева Т.А. ауд. 33н").subject, "(л.з.) Алексеева Т.А. ауд. 33н");
});
test("Разрядка, «со 2 по 14 нед.», город с середины дня", () => {
// Letters spaced out for show, words apart by several spaces.
assert.equal(clean("Ф и з и ч е с к а я к у л ь т у р а"), "Физическая культура");
assert.equal(clean("З а н я т и я в П о л о ц к е"), "Занятия в Полоцке");
assert.equal(clean("со 2 по 14 нед. М о д у л ь « М а й н о р »"), "2-14 нед. Модуль «Майнор»");
// Initials and short words stay as they are.
assert.equal(clean("Борун Е.Н. ауд. 100"), "Борун Е.Н. ауд. 100");
// "Занятия в Полоцке" in the 13:40 row: the morning is not in Polotsk.
const places = [{ day: 5, date: null, groups: ["25-ИТ-1"], subgroups: [], place: "Полоцк", from: "13:40" }];
const at = (start) => placeOf({ day: 5, date: null, start, groups: ["25-ИТ-1"], subgroups: [] }, places, () => []);
assert.equal(at("08:30"), null);
assert.equal(at("15:20"), "Полоцк");
});
test("Старое имя разделённой группы: понятная ошибка, другое написание — та же группа", async () => {
const { timetable } = await import("../server/timetable.js");
const schedule = { groups: ["24-ИЯ (РА)и-1", "24-ИЯ (РА)и-2"], lessons: [], notes: [], period: "Осенний семестр", source: {} };

View File

@@ -19,7 +19,7 @@
вт 11:45–13:10 | каждую неделю, нижняя строка | Стандартизация программного обеспечения | Лекция | 321Д | Михнович О.В. | 24-ИТ-1,24-ИТ-2
вт 13:40–15:05 | каждую неделю, верхняя строка | Метрология, стандартизация и сертификация (в информационных технологиях | Практика | 321Д | Михнович О.В. | 24-ИТ-1,24-ИТ-2
вт 13:40–15:05 | каждую неделю, нижняя строка | Стандартизация программного обеспечения | Практика | 321Д | Михнович О.В. | 24-ИТ-1,24-ИТ-2
вт 15:20–16:45 | каждую неделю | Ф И З И Ч Е С К А Я К У Л Ь Т У Р А | Занятие | | | 24-ИТ-1,24-ИТ-2
вт 15:20–16:45 | каждую неделю | ФИЗИЧЕСКАЯ КУЛЬТУРА | Занятие | | | 24-ИТ-1,24-ИТ-2
пн 08:30–09:55 | каждую неделю, верхняя строка | Объектно-ориентированные технологии програм-мирования и стандарты проектирования | Лабораторная | 311А | Гордевич Д.А. | 24-ИТ-1,24-ИТ-1/п3
пн 08:30–09:55 | каждую неделю, верхняя строка | Объектно-ориентированные технологии програм-мирования и стандарты проектирования | Лабораторная | 311А | Гордевич Д.А. | 24-ИТ-2,24-ИТ-2/п3
пн 08:30–09:55 | каждую неделю, нижняя строка | Системное программирование | Лабораторная | 207Г | Юрчишко Е.И. | 24-ИТ-1,24-ИТ-1/п4
@@ -79,10 +79,10 @@
сб 11:45–13:10 | даты 2026-09-12,2026-09-26,2026-10-10,2026-10-24, нижняя строка | Основы информационной безопасности | Практика | 321Д | Пастухов Ю.Ф. | 24-ИТ-2
сб 11:45–13:10 | каждую неделю, верхняя строка | Основы информационной безопасности | Практика | 321Д | Пастухов Ю.Ф. | 24-ИТ-1
сб 11:45–13:10 | каждую неделю, верхняя строка | Основы информационной безопасности | Практика | 321Д | Пастухов Ю.Ф. | 24-ИТ-2
ср 10:05–11:30 | каждую неделю | со 2 по 14 нед. М о д у л ь « М а й н о р » | Занятие | | | 24-ИТ-1,24-ИТ-2
ср 10:05–11:30 | нед. 2-14 | Модуль «Майнор» | Занятие | | | 24-ИТ-1,24-ИТ-2
чт 08:30–09:55 | каждую неделю, нижняя строка | Системное программирование | Лабораторная | 311А | Скуковская А.А. | 24-ИТ-1,24-ИТ-1/п2
чт 08:30–09:55 | каждую неделю, нижняя строка | Системное программирование | Лабораторная | 311А | Скуковская А.А. | 24-ИТ-2,24-ИТ-2/п2
чт 10:05–11:30 | каждую неделю | Ф И З И Ч Е С К А Я К У Л Ь Т У Р А | Занятие | | | 24-ИТ-1,24-ИТ-2
чт 10:05–11:30 | каждую неделю | ФИЗИЧЕСКАЯ КУЛЬТУРА | Занятие | | | 24-ИТ-1,24-ИТ-2
чт 11:45–13:10 | каждую неделю, верхняя строка | Базы данных | Лабораторная | 218Д | Дьякова А.С. | 24-ИТ-1,24-ИТ-1/п2
чт 11:45–13:10 | каждую неделю, верхняя строка | Базы данных | Лабораторная | 218Д | Дьякова А.С. | 24-ИТ-2,24-ИТ-2/п2
чт 11:45–13:10 | каждую неделю, верхняя строка | Веб-технологии | Лабораторная | 207Г | Аврусевич К.Д. | 24-ИТ-1,24-ИТ-1/п4