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п/гр. Экономика…» — занятие одной подгруппы. - **Подгруппа в тексте.** «12н.2п/гр. Экономика…» — занятие одной подгруппы.
- **Разрядка** (парсер 19): «Ф и з и ч е с к а я к у л ь т у р а», «З а н я т и я в П о л о ц к е» — буквы через пробел, слова через несколько. Склеивается до сжатия пробелов: «Физическая культура», «Занятия в Полоцке» (теперь это и заметка о городе). Инициалы и короткие слова не трогаются.
- **«со 2 по 14 нед.»** читается как «2-14 нед.»; пробелы внутри кавычек («« Майнор »») убираются.
- **Вид занятия после преподавателя** (парсер 18): «Русский язык как иностранный Козыренко А.Н.(п.з.) ауд.100» — название кончается там, где начинается преподаватель или вид, смотря что раньше. Раньше фамилия попадала в название (25‑ЭПи, 25‑ЭМ). - **Вид занятия после преподавателя** (парсер 18): «Русский язык как иностранный Козыренко А.Н.(п.з.) ауд.100» — название кончается там, где начинается преподаватель или вид, смотря что раньше. Раньше фамилия попадала в название (25‑ЭПи, 25‑ЭМ).
- **Ячейка из одной точки** — не занятие. - **Ячейка из одной точки** — не занятие.
- **Время в начале ячейки:** - **Время в начале ячейки:**
@@ -170,6 +172,7 @@ npm run sync
**Занятия в другом городе** (`server/places.js`, парсер 14). Большинство пар — в Новополоцке, но часть факультетов учится в Полоцке, и Word отмечает это по‑разному: **Занятия в другом городе** (`server/places.js`, парсер 14). Большинство пар — в Новополоцке, но часть факультетов учится в Полоцке, и Word отмечает это по‑разному:
- **«Занятия в Полоцке» над первой парой дня** (24‑ИЯ (РА)и‑1…6, 25‑ИЯ (РА)и‑3, 24‑РГФи‑1, 22‑ЛОМК, 25‑ТДи) — не занятие, а заметка дня для столбца: в этот день все пары группы или подгруппы идут в том городе (и аудитории у них полоцкие — Г, Д, Е, Ж). Заметка лежит в разобранном документе в `places` (`{day, date, groups, subgroups, place, text}`), занятиям этого дня проставляется `place`. Если в той же ячейке под заметкой есть пара, это обычное занятие без приставки в названии. Занятие всей группы получает город, когда заметка стоит во всех её подгруппах. - **«Занятия в Полоцке» над первой парой дня** (24‑ИЯ (РА)и‑1…6, 25‑ИЯ (РА)и‑3, 24‑РГФи‑1, 22‑ЛОМК, 25‑ТДи) — не занятие, а заметка дня для столбца: в этот день все пары группы или подгруппы идут в том городе (и аудитории у них полоцкие — Г, Д, Е, Ж). Заметка лежит в разобранном документе в `places` (`{day, date, groups, subgroups, place, text}`), занятиям этого дня проставляется `place`. Если в той же ячейке под заметкой есть пара, это обычное занятие без приставки в названии. Занятие всей группы получает город, когда заметка стоит во всех её подгруппах.
- **Город строкой в ячейке занятия** — «Новополоцк», «г.Новополоцк» — или **после аудитории** — «246(Новополоцк)», «121(г.Новополоцк)» — город этого занятия; из названия и аудитории он убирается. - **Город строкой в ячейке занятия** — «Новополоцк», «г.Новополоцк» — или **после аудитории** — «246(Новополоцк)», «121(г.Новополоцк)» — город этого занятия; из названия и аудитории он убирается.
- **Заметка посреди дня** (25‑ИТ, пятница: «Занятия в Полоцке» в строке 13:40) действует с этой пары: в разобранном документе у неё `from`, утренние пары города не получают. `/api/timetable` отдаёт `from` в `places`, Android пишет «С 13:40 — занятия в Полоцке».
- Пишется и «Занятия в г. Новополоцк», «Занятия проходят в …», «Занятия в Междуречье». Город приводится к именительному падежу («Полоцке» → «Полоцк»). Название предмета вроде «История Полоцка» городом не считается. - Пишется и «Занятия в г. Новополоцк», «Занятия проходят в …», «Занятия в Междуречье». Город приводится к именительному падежу («Полоцке» → «Полоцк»). Название предмета вроде «История Полоцка» городом не считается.
- В документах «Введение в высшее образование» строка «г. Полоцк, Дом офицеров» даёт занятию город, а место проведения остаётся в тексте. - В документах «Введение в высшее образование» строка «г. Полоцк, Дом офицеров» даёт занятию город, а место проведения остаётся в тексте.
- Какие заметки показать над днём группы, решает общий модуль `shared/places.js`: им пользуются и `/api/timetable`, и PWA. Одинаковая заметка во всех подгруппах сводится к одной для всей группы, а заметка своей подгруппы показывается как заметка всего дня. - Какие заметки показать над днём группы, решает общий модуль `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: «Обзор» — карточки и недельная сетка, «Планы» и «Календарь» — список и выбранный день, «Поиск» — результаты и день преподавателя или аудитории; «Предметы» — сеткой карточек, «Расписание» открывается неделей; карточки лежат на подложке с оттенком, системные панели следуют теме приложения. В папке `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 — получают планшетный макет: - **Фолды** (Galaxy Z Fold, Pixel Fold) в разложенном виде — окно через сгиб с короткой стороной от 600dp — получают планшетный макет:
- «Обзор», «Планы», «Календарь» и «Поиск» — две панели, как две страницы, по обе стороны вертикального сгиба, и в плоском, и в полураскрытом виде; - «Обзор», «Планы», «Календарь» и «Поиск» — две панели, как две страницы, по обе стороны вертикального сгиба, и в плоском, и в полураскрытом виде;
- «Расписание» открывается неделей на карточке; - «Расписание» открывается неделей на карточке;
@@ -267,9 +270,14 @@ DOMAIN=schedule.example.by docker compose --profile https up -d --build
- **Сегодня — на текущей паре.** Страница сегодняшнего дня открывается на идущей паре (или следующей), а не на первой утренней. - **Сегодня — на текущей паре.** Страница сегодняшнего дня открывается на идущей паре (или следующей), а не на первой утренней.
- **Виброотклик:** тик при выборе дня в ленте и при листании дней пальцем, при выборе в кнопках-переключателях. Подтверждение при выполнении задания и отметке посещения, щелчок у переключателей настроек. - **Виброотклик:** тик при выборе дня в ленте и при листании дней пальцем, при выборе в кнопках-переключателях. Подтверждение при выполнении задания и отметке посещения, щелчок у переключателей настроек.
- **Скелетоны вместо спиннера:** пока грузится первое расписание группы, «Обзор», «Расписание» и «Предметы» показывают серые заготовки в форме своих карточек с мягким бликом. Так же выглядят занятия преподавателя или аудитории в поиске и каталог групп. Для TalkBack это одно «Загрузка». При системной настройке «Убрать анимацию» блик, появление карточек и прочие украшения отключаются. - **Скелетоны вместо спиннера:** пока грузится первое расписание группы, «Обзор», «Расписание» и «Предметы» показывают серые заготовки в форме своих карточек с мягким бликом. Так же выглядят занятия преподавателя или аудитории в поиске и каталог групп. Для TalkBack это одно «Загрузка». При системной настройке «Убрать анимацию» блик, появление карточек и прочие украшения отключаются.
- **Карточка вырастает в подробности** (container transform). Пара открывается из своей карточки: из «Обзора», расписания по дням, недельной сетки, календаря «Планов» и подставки «ноутбука». Рамка плавно растягивается до полного экрана (на планшете — до большой панели по центру), картинка карточки растворяется, подробности проявляются. «Назад», предиктивный жест и касание за панелью сворачивают её обратно в карточку. Так же «+» в «Планах» и календаре раскрывается в лист нового задания; лист можно стянуть вниз за ручку. - **Карточка вырастает в подробности** (container transform). Пара открывается из своей карточки: из «Обзора», расписания по дням, недельной сетки, календаря «Планов» и подставки «ноутбука». Рамка плавно растягивается до полного экрана (на планшете — до большой панели по центру), а содержимое едет вместе с ней, как в M3 (container transform из Material Components): и карточка, и страница подробностей масштабируются по ширине рамки от её левого верхнего угла, так что заголовок и сведения о паре выходят из самой карточки. Страница проявляется поверх карточки за первую четверть пути, углы выпрямляются за три четверти; при закрытии она гаснет поздно, между 60 и 90 % пути, и рамка с карточкой внутри сжимается на место. «Назад», предиктивный жест и касание за панелью сворачивают её обратно в карточку. Так же «+» в «Планах» и календаре раскрывается в лист нового задания; лист можно стянуть вниз за ручку, и он тоже сжимается обратно в «+». Контейнер рисует только содержимое источника (иконку «+», текст карточки), а заливку, углы и тень — свои: у «+» тень плавно уходит при росте и возвращается к его уровню при посадке, так что ни второй формы внутри, ни скачка тени в конце нет.
- **Живая текущая пара.** Вокруг идущей пары по часовой стрелке от левого верхнего угла рисуется рамка: какая часть пары прошла. Она подрастает плавно, без скачков. Цифры обратного отсчёта («ещё 44 мин», «Через 15 мин») прокручиваются, как на табло. Линия «сейчас» в недельной сетке ползёт вниз вместе со временем. - **Живая текущая пара.** Вокруг идущей пары по часовой стрелке от левого верхнего угла рисуется рамка: какая часть пары прошла. Она подрастает плавно, без скачков. Цифры обратного отсчёта («ещё 44 мин», «Через 15 мин») прокручиваются, как на табло. Линия «сейчас» в недельной сетке ползёт вниз вместе со временем.
- **Лента дней.** Выбранный день — один круг под всей лентой: при выборе другого дня он перетекает туда на пружинах, передний край спешит, задний догоняет, и по дороге круг вытягивается в каплю. В «Расписании» точка в круге дня значит «есть пары». Когда пролистываешь на другую неделю, её точки появляются по очереди, с понедельника. - **Лента дней.** Выбранный день — один круг под всей лентой: при выборе другого дня он перетекает туда на пружинах, передний край спешит, задний догоняет, и по дороге круг вытягивается в каплю. В «Расписании» точка в круге дня значит «есть пары». Когда пролистываешь на другую неделю, её точки появляются по очереди, с понедельника.
- **Долгое нажатие на пару** (в расписании, недельной сетке и «Обзоре») открывает лист действий: «Подробнее» (страница вырастает из той же карточки), «Задание или заметка», «Был / Пропустил», когда пара уже началась, «Поделиться» (предмет, вид, день, время, аудитория, преподаватель) и «Скрыть такие занятия» с «Вернуть». Для TalkBack — действие «Действия» у карточки.
- **Между парами** в расписании дня — строка, когда есть что сказать: «Окно 1 ч 35 мин» (от 45 минут, то есть пропущена пара), «Другой корпус: 211А → 254В · перерыв 30 мин» (корпус — буквы после номера аудитории), «Переезд в Полоцк» по городу пары; переезд с перерывом меньше часа выделен красным. Обычные перерывы 10–30 минут и дистанционные пары строки не дают.
- **Избранное и недавние в поиске.** Звёздочка у преподавателя или аудитории добавляет их в «Избранное»; до ввода запроса поиск показывает «Избранное» и «Недавние» (последние восемь открытых). Касание открывает сегодняшний день, и у идущей пары — «Идёт · ещё 40 мин» с рамкой прогресса, у ближайшей — «Через 15 мин»: где преподаватель сейчас, видно сразу.
- **Свайпы по заданиям** (как в Buckwheat). Вправо — «Выполнено», у выполненного — «Не выполнено». Влево — «Перенести»: задание переезжает к следующей паре того же предмета, общий план — на завтра; внизу «Перенесено на пн, 26 октября, 15:20 · Вернуть». Под строкой открывается прямоугольная полоса с иконкой действия; за порогом цвет действия заливает её кругом от иконки, иконка подпрыгивает, телефон щёлкает. Отпущенная строка всегда пружинит на место. Пока студент ни разу не свайпнул задание, одна строка на экране сама покачивается в стороны и показывает действия под собой; касание её останавливает, первый свайп убирает подсказку насовсем. На «Доске» свайпов нет: там жест листает дни. Для TalkBack те же действия — в меню действий строки.
- **Смена темы.** «Светлая», «Тёмная» и «Цвета обоев» в настройках меняют тему кругом, который расходится от нажатой кнопки: снимок старого экрана лежит поверх приложения, и в нём растёт дыра с новой темой (`ui/theme/ThemeReveal.kt`). Значки строки состояния рисует система, они меняются сразу.
- **Выполненное задание.** При отметке текст зачёркивается слева направо, строка за строкой, и задание чуть задерживается на месте, прежде чем уйти к выполненным. Когда закрыто последнее открытое, у «Всё сделано» выскакивает галочка. - **Выполненное задание.** При отметке текст зачёркивается слева направо, строка за строкой, и задание чуть задерживается на месте, прежде чем уйти к выполненным. Когда закрыто последнее открытое, у «Всё сделано» выскакивает галочка.
- **Обновление жестом.** Потянуть вниз в «Обзоре» и в расписании: экспрессивный индикатор M3 с меняющейся формой и щелчок отклика, когда тянуть уже достаточно. - **Обновление жестом.** Потянуть вниз в «Обзоре» и в расписании: экспрессивный индикатор 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»…), или документ ушёл из каталога, — плашка с текстом сервера предлагает «Выбрать группу». Сохранённая копия остаётся на экране, а устаревшая группа убирается из недавних. - **Группа, которой больше нет.** Если сервер отвечает `group_not_found` или `document_not_found` — группу разделили («24‑ИЯ(РА)» стала «24‑ИЯ (РА)и‑1»…), или документ ушёл из каталога, — плашка с текстом сервера предлагает «Выбрать группу». Сохранённая копия остаётся на экране, а устаревшая группа убирается из недавних.
- **Изменения в расписании.** Как в PWA: когда университет перезаливает документ группы, приложение сравнивает новое расписание с копией на устройстве. В «Обзоре» появляется карточка «Расписание обновлено · N изменений» с кнопкой «Показать», а в списке видно, что изменилось, что добавлено и что удалено, например «Аудитория: 374 → 375». Карточка показывается до просмотра или закрытия, последний список остаётся в настройках («Последние изменения»). Сравнение срабатывает и при фоновом обновлении (`SyncWorker`, раз в 6 часов). Если изменения нашлись в фоне, приходит уведомление «Расписание 23-АРХ изменилось» с первыми изменениями, например «Проектирование общественных зданий: аудитория 374 → 375». Уведомление локальное, сервер push для него не нужен. Его можно выключить в настройках («Изменения в расписании», по умолчанию включено), а на Android 13+ нужно разрешение на уведомления. Считается только новая версия того же файла: смена версии парсера или переход на другой документ изменениями не считаются. Сравниваются строки Word по дням недели и разовые занятия со своей датой, а не каждая рассчитанная дата. - **Изменения в расписании.** Как в PWA: когда университет перезаливает документ группы, приложение сравнивает новое расписание с копией на устройстве. В «Обзоре» появляется карточка «Расписание обновлено · N изменений» с кнопкой «Показать», а в списке видно, что изменилось, что добавлено и что удалено, например «Аудитория: 374 → 375». Карточка показывается до просмотра или закрытия, последний список остаётся в настройках («Последние изменения»). Сравнение срабатывает и при фоновом обновлении (`SyncWorker`, раз в 6 часов). Если изменения нашлись в фоне, приходит уведомление «Расписание 23-АРХ изменилось» с первыми изменениями, например «Проектирование общественных зданий: аудитория 374 → 375». Уведомление локальное, сервер push для него не нужен. Его можно выключить в настройках («Изменения в расписании», по умолчанию включено), а на Android 13+ нужно разрешение на уведомления. Считается только новая версия того же файла: смена версии парсера или переход на другой документ изменениями не считаются. Сравниваются строки Word по дням недели и разовые занятия со своей датой, а не каждая рассчитанная дата.
- Задания и заметки к паре: из карточки занятия — к этой паре или к следующей паре того же предмета. Задания отмечаются выполненными, просроченные подсвечиваются; список — в «Обзоре», по предмету — в «Предметах», точка на блоке в недельной сетке. Хранятся только на устройстве (`notes.json`) и привязаны к группе. - Задания и заметки к паре: из карточки занятия — к этой паре или к следующей паре того же предмета. Задания отмечаются выполненными, просроченные подсвечиваются; список — в «Обзоре», по предмету — в «Предметах», точка на блоке в недельной сетке. Хранятся только на устройстве (`notes.json`) и привязаны к группе.
- **Фото к заданиям:** в редакторе задания или заметки — «Снять» (приложение камеры) и «Галерея» (системный выбор фото, без доступа к памяти), до 6 фото. Фото уменьшается до 2048 px по длинной стороне, поворачивается по EXIF и хранится в `files/photos/` рядом с заметками. Удаление фото, задания или отмена правки убирает файлы, а при запуске удаляются фото, на которые не ссылается ни одна заметка. В списках у задания миниатюра (с «+N»), на карточке пары — значок фото. Просмотр — на весь экран, с листанием, масштабом щипком и двойным касанием. Задание может быть и одним фото, без текста. - **Фото к заданиям:** в редакторе задания или заметки — «Снять» (приложение камеры) и «Галерея» (системный выбор фото, без доступа к памяти), до 6 фото. Фото уменьшается до 2048 px по длинной стороне, поворачивается по EXIF и хранится в `files/photos/` рядом с заметками. Удаление фото, задания или отмена правки убирает файлы, а при запуске удаляются фото, на которые не ссылается ни одна заметка. В списках у задания миниатюра (с «+N»), на карточке пары — значок фото. Просмотр — на весь экран, с листанием, масштабом щипком и двойным касанием. Он открывается по M3 как container transform для «героического» момента: фото вырастает из миниатюры (в строке задания или в редакторе) из квадратной обрезки в целый снимок, углы выпрямляются, вокруг поднимается чёрный фон, а при закрытии (крестик, «Назад», предиктивный жест) фото сжимается обратно в свою миниатюру; у фото без своей миниатюры — в ту, из которой открыли, и на последнем отрезке перетекает в её снимок. Миниатюра запоминает место на экране, поэтому переход работает и из диалога редактора. Задание может быть и одним фото, без текста.
- Посещаемость: «был» или «пропустил» в карточке занятия и одним касанием в «Обзоре» для прошедших сегодня пар; статистика по каждому предмету в «Предметах». Хранится на устройстве (`attendance.json`). - Посещаемость: «был» или «пропустил» в карточке занятия и одним касанием в «Обзоре» для прошедших сегодня пар; статистика по каждому предмету в «Предметах». Хранится на устройстве (`attendance.json`).
- Скрытие занятий: «Скрыть такие занятия» в карточке занятия убирает предмет с этим типом занятия из расписания, обзора, виджетов и напоминаний; вернуть можно в настройках. - Скрытие занятий: «Скрыть такие занятия» в карточке занятия убирает предмет с этим типом занятия из расписания, обзора, виджетов и напоминаний; вернуть можно в настройках.
- Второй виджет «Следующая пара» (4×1) и плитка в шторке быстрых настроек с текущей или следующей парой и аудиторией. - Второй виджет «Следующая пара» (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/index.js` — API и выдача собранной PWA.
- `server/limits.js` — ограничения импорта: число одновременных разборов и запросов с одного адреса. - `server/limits.js` — ограничения импорта: число одновременных разборов и запросов с одного адреса.
- `server/errors.js` — коды ошибок API и их HTTP-статусы. - `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/Shared/` — модели, даты в часовом поясе Минска, расчёт пар и общее хранилище; общие для приложения и виджетов.
- `ios/PSUSchedule/` — приложение iOS: `AppModel.swift` (состояние и загрузка), `API.swift`, `Reminders.swift`, экраны в `Views/`. - `ios/PSUSchedule/` — приложение iOS: `AppModel.swift` (состояние и загрузка), `API.swift`, `Reminders.swift`, экраны в `Views/`.
- `ios/Widgets/` — виджеты WidgetKit. - `ios/Widgets/` — виджеты WidgetKit.

View File

@@ -19,8 +19,8 @@ android {
applicationId = "org.xtimms.psu.schedule" applicationId = "org.xtimms.psu.schedule"
minSdk = 26 minSdk = 26
targetSdk = 37 targetSdk = 37
versionCode = 1 versionCode = 2
versionName = "0.1.0" versionName = "0.1.1"
buildConfigField("String", "API_URL", "\"${apiUrl.get()}\"") 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 kotlin.coroutines.cancellation.CancellationException
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import org.xtimms.psu.schedule.ui.UndoArea import org.xtimms.psu.schedule.ui.components.UndoArea
import org.xtimms.psu.schedule.ui.LocalMorph import org.xtimms.psu.schedule.ui.motion.LocalMorph
import org.xtimms.psu.schedule.ui.MorphHost import org.xtimms.psu.schedule.ui.motion.MorphHost
import org.xtimms.psu.schedule.ui.MorphOverlay import org.xtimms.psu.schedule.ui.motion.MorphOverlay
import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.rememberTransition import androidx.compose.animation.core.rememberTransition
import androidx.compose.animation.core.SeekableTransitionState 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.outlined.Checklist
import androidx.compose.material.icons.rounded.CalendarViewWeek import androidx.compose.material.icons.rounded.CalendarViewWeek
import androidx.compose.material.icons.rounded.Checklist import androidx.compose.material.icons.rounded.Checklist
import org.xtimms.psu.schedule.ui.CalendarScreen import org.xtimms.psu.schedule.ui.plans.CalendarScreen
import org.xtimms.psu.schedule.ui.PlansScreen import org.xtimms.psu.schedule.ui.plans.PlansScreen
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.rememberScrollState 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.Column
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.material.icons.automirrored.rounded.MenuOpen import androidx.compose.material.icons.automirrored.rounded.MenuOpen
import org.xtimms.psu.schedule.ui.materialFadeThrough import org.xtimms.psu.schedule.ui.motion.materialFadeThrough
import org.xtimms.psu.schedule.ui.Emphasized import org.xtimms.psu.schedule.ui.motion.Emphasized
import org.xtimms.psu.schedule.ui.MotionDuration import org.xtimms.psu.schedule.ui.motion.MotionDuration
import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import org.xtimms.psu.schedule.ui.materialSharedAxisX import org.xtimms.psu.schedule.ui.motion.materialSharedAxisX
import org.xtimms.psu.schedule.ui.rememberSlideDistance import org.xtimms.psu.schedule.ui.motion.rememberSlideDistance
import android.graphics.Color import android.graphics.Color
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
@@ -60,10 +60,10 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.slideOutHorizontally
import org.xtimms.psu.schedule.ui.LocalHinge import org.xtimms.psu.schedule.ui.components.LocalHinge
import org.xtimms.psu.schedule.ui.EmphasizedDecelerate import org.xtimms.psu.schedule.ui.motion.EmphasizedDecelerate
import org.xtimms.psu.schedule.ui.EmphasizedAccelerate import org.xtimms.psu.schedule.ui.motion.EmphasizedAccelerate
import org.xtimms.psu.schedule.ui.LocalFoldTablet import org.xtimms.psu.schedule.ui.components.LocalFoldTablet
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row 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.ThemeMode
import org.xtimms.psu.schedule.data.today import org.xtimms.psu.schedule.data.today
import org.xtimms.psu.schedule.ui.AppNav import org.xtimms.psu.schedule.ui.AppNav
import org.xtimms.psu.schedule.ui.GroupsScreen import org.xtimms.psu.schedule.ui.groups.GroupsScreen
import org.xtimms.psu.schedule.ui.HomeScreen import org.xtimms.psu.schedule.ui.home.HomeScreen
import org.xtimms.psu.schedule.ui.Onboarding import org.xtimms.psu.schedule.ui.onboarding.Onboarding
import org.xtimms.psu.schedule.ui.LocalDisplay import org.xtimms.psu.schedule.ui.lesson.LocalDisplay
import org.xtimms.psu.schedule.ui.LocalMarks import org.xtimms.psu.schedule.ui.lesson.LocalMarks
import org.xtimms.psu.schedule.ui.LocalMenuProgress import org.xtimms.psu.schedule.ui.LocalMenuProgress
import org.xtimms.psu.schedule.ui.theme.card import org.xtimms.psu.schedule.ui.theme.card
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import org.xtimms.psu.schedule.ui.LocalNotes import org.xtimms.psu.schedule.ui.notes.LocalNotes
import org.xtimms.psu.schedule.ui.LocalSubjectSlots import org.xtimms.psu.schedule.ui.lesson.LocalSubjectSlots
import org.xtimms.psu.schedule.data.Display import org.xtimms.psu.schedule.data.Display
import org.xtimms.psu.schedule.ui.MainViewModel import org.xtimms.psu.schedule.ui.MainViewModel
import org.xtimms.psu.schedule.ui.ScheduleScreen import org.xtimms.psu.schedule.ui.schedule.ScheduleScreen
import org.xtimms.psu.schedule.ui.SearchScreen import org.xtimms.psu.schedule.ui.search.SearchScreen
import org.xtimms.psu.schedule.ui.SettingsScreen import org.xtimms.psu.schedule.ui.settings.SettingsScreen
import org.xtimms.psu.schedule.ui.SubjectsScreen import org.xtimms.psu.schedule.ui.subjects.SubjectsScreen
import org.xtimms.psu.schedule.ui.subjectSlots import org.xtimms.psu.schedule.ui.lesson.subjectSlots
import org.xtimms.psu.schedule.ui.theme.ScheduleTheme 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.isDark
import org.xtimms.psu.schedule.ui.theme.page import org.xtimms.psu.schedule.ui.theme.page
import java.time.LocalDate 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) { private enum class Tab(val label: String, val icon: ImageVector, val selectedIcon: ImageVector) {
Home("Обзор", Icons.Outlined.Home, Icons.Rounded.Home), Home("Обзор", Icons.Outlined.Home, Icons.Rounded.Home),
@@ -193,9 +197,11 @@ class MainActivity : ComponentActivity() {
onDispose {} onDispose {}
} }
// The fold of a foldable phone, for screens that split their panes around it. // 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) { 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()) { Surface(color = MaterialTheme.colorScheme.page, modifier = Modifier.fillMaxSize()) {
// Wait for the stored settings so a returning student never sees the onboarding. // Wait for the stored settings so a returning student never sees the onboarding.
if (s != null) { if (s != null) {
@@ -207,11 +213,18 @@ class MainActivity : ComponentActivity() {
label = "onboarding", label = "onboarding",
) { first -> ) { first ->
if (first) Onboarding(vm, onDone = { onboarding = false }) 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() } val morph = remember { MorphHost() }
UndoArea(Modifier.fillMaxSize()) { UndoArea(Modifier.fillMaxSize()) {
androidx.compose.runtime.CompositionLocalProvider(LocalMorph provides morph) { 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)) { BoxWithConstraints(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.card)) {
val wide = maxWidth >= MEDIUM_WIDTH val wide = maxWidth >= MEDIUM_WIDTH
// An open book-style foldable gets the tablet layout; a flip phone keeps the phone one. // 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. // A card's details and the "+" sheet open over everything, growing from what was tapped.
MorphOverlay(morph) 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). * when `subgroup` is set) are in another town (server/timetable.js placesOn).
*/ */
@Serializable @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. */ /** 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> = fun List<DayPlace>.forSubgroup(subgroup: String?): List<DayPlace> =

View File

@@ -56,6 +56,12 @@ data class UserSettings(
val roomTime: Int = 7 * 60, val roomTime: Int = 7 * 60,
/** The lesson under way as an ongoing notification (a Live Update on Android 16). */ /** The lesson under way as an ongoing notification (a Live Update on Android 16). */
val liveLesson: Boolean = false, 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 recent: List<SavedGroup> = emptyList(),
val display: Display = Display(), val display: Display = Display(),
/** /**
@@ -90,6 +96,9 @@ class Settings(private val context: Context) {
val roomAlerts = booleanPreferencesKey("room_alerts") val roomAlerts = booleanPreferencesKey("room_alerts")
val roomTime = intPreferencesKey("room_time") val roomTime = intPreferencesKey("room_time")
val liveLesson = booleanPreferencesKey("live_lesson") 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 recent = stringPreferencesKey("recent")
val colors = booleanPreferencesKey("show_colors") val colors = booleanPreferencesKey("show_colors")
val teachers = booleanPreferencesKey("show_teachers") val teachers = booleanPreferencesKey("show_teachers")
@@ -103,6 +112,9 @@ class Settings(private val context: Context) {
val plansDone = booleanPreferencesKey("plans_done") 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>> = private fun Preferences.choiceMap(): Map<String, Map<String, String>> =
this[Keys.choices]?.let { runCatching { json.decodeFromString<Map<String, Map<String, String>>>(it) }.getOrNull() }.orEmpty() 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, roomAlerts = this[Keys.roomAlerts] ?: true,
roomTime = this[Keys.roomTime] ?: 7 * 60, roomTime = this[Keys.roomTime] ?: 7 * 60,
liveLesson = this[Keys.liveLesson] ?: false, liveLesson = this[Keys.liveLesson] ?: false,
swipeTried = this[Keys.swipeTried] ?: false,
searchFavorites = list(Keys.searchFavorites),
searchRecent = list(Keys.searchRecent),
recent = this[Keys.recent]?.let { recent = this[Keys.recent]?.let {
runCatching { json.decodeFromString<List<SavedGroup>>(it) }.getOrNull() runCatching { json.decodeFromString<List<SavedGroup>>(it) }.getOrNull()
}.orEmpty(), }.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 setRoomAlerts(on: Boolean) = context.store.edit { it[Keys.roomAlerts] = on }
suspend fun setRoomTime(minutes: Int) = context.store.edit { it[Keys.roomTime] = minutes } 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 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. */ /** Hides or shows again a recurring lesson of the open group. */
suspend fun setHidden(key: String, hidden: Boolean) = context.store.edit { prefs -> 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.dayName
import org.xtimms.psu.schedule.data.longLabel import org.xtimms.psu.schedule.data.longLabel
import org.xtimms.psu.schedule.data.parseDate import org.xtimms.psu.schedule.data.parseDate
import org.xtimms.psu.schedule.ui.changeDate import org.xtimms.psu.schedule.ui.home.changeDate
import org.xtimms.psu.schedule.ui.changesLabel import org.xtimms.psu.schedule.ui.home.changesLabel
/** /**
* A notification when the background sync finds that the university republished the group's * 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() val at = date.minusDays(1).atTime(LocalTime.ofSecondOfDay(settings.travelTime * 60L)).atZone(MINSK).toInstant()
if (at.isAfter(now)) { if (at.isAfter(now)) {
val note = places.first() 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}" + val text = "Первая пара в ${first.start} — ${first.subject}" +
(withPlace(rooms(first), first.place)?.let { ", $it" } ?: "") + (withPlace(rooms(first), first.place)?.let { ", $it" } ?: "") +
". Всего ${lessons.distinctBy { it.start }.size} ${plural(lessons.distinctBy { it.start }.size, "пара", "пары", "пар")}." ". Всего ${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.parseTime
import org.xtimms.psu.schedule.data.today import org.xtimms.psu.schedule.data.today
import org.xtimms.psu.schedule.data.withPlace 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.Duration
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalDateTime 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.Timetable
import org.xtimms.psu.schedule.data.UserSettings import org.xtimms.psu.schedule.data.UserSettings
import org.xtimms.psu.schedule.work.SyncWorker 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) { 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) } 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. */ /** The key new notes are filed under, or null before a group is chosen. */
fun currentGroupKey(): String? = settings.value?.current?.let(::groupKey) fun currentGroupKey(): String? = settings.value?.current?.let(::groupKey)
@@ -170,6 +195,11 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
SyncWorker.afterChange(getApplication()) 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 setTheme(mode: ThemeMode) = viewModelScope.launch { app.settings.setTheme(mode) }
fun setDynamicColor(on: Boolean) = viewModelScope.launch { app.settings.setDynamicColor(on) } fun setDynamicColor(on: Boolean) = viewModelScope.launch { app.settings.setDynamicColor(on) }
fun setDisplay(display: Display) = viewModelScope.launch { app.settings.setDisplay(display) } 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) @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.platform.LocalHapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType 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.data.weekSpan
import org.xtimms.psu.schedule.ui.theme.WeekColors import org.xtimms.psu.schedule.ui.theme.WeekColors
import java.time.LocalDate 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. */ /** A tablet, by its smallest side: its app bars stay calm, as School Planner's do. */
@Composable @Composable
@@ -176,7 +183,11 @@ fun Heading(
Text(text, style = style, modifier = modifier.semantics { 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 @Composable
fun <T> ConnectedChoice( fun <T> ConnectedChoice(
options: List<Pair<T, String>>, options: List<Pair<T, String>>,
@@ -187,42 +198,56 @@ fun <T> ConnectedChoice(
mark: Boolean = true, mark: Boolean = true,
) { ) {
val haptic = LocalHapticFeedback.current val haptic = LocalHapticFeedback.current
Row( val measurer = rememberTextMeasurer()
modifier.selectableGroup(), val style = MaterialTheme.typography.labelLarge
horizontalArrangement = Arrangement.spacedBy(ButtonGroupDefaults.ConnectedSpaceBetween), val density = LocalDensity.current
) { BoxWithConstraints(modifier) {
options.forEachIndexed { index, (value, label) -> val gap = ButtonGroupDefaults.ConnectedSpaceBetween
val checked = value == selected val segment = (maxWidth - gap * (options.size - 1)) / options.size
ToggleButton( Row(
checked = checked, Modifier.fillMaxWidth().selectableGroup(),
onCheckedChange = { horizontalArrangement = Arrangement.spacedBy(gap),
if (!checked) haptic.performHapticFeedback(HapticFeedbackType.SegmentTick) ) {
onSelect(value) options.forEachIndexed { index, (value, label) ->
}, val checked = value == selected
modifier = Modifier.weight(1f).semantics { role = Role.RadioButton }, val text = with(density) { measurer.measure(label, style, maxLines = 1).size.width.toDp() }
// Unselected segments need their own tonal fill to read as one group. val check = checked && mark && text + CHECK + PADDING * 2 <= segment
colors = ToggleButtonDefaults.colors( ToggleButton(
containerColor = MaterialTheme.colorScheme.secondaryContainer, checked = checked,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer, onCheckedChange = {
checkedContainerColor = MaterialTheme.colorScheme.primary, if (!checked) haptic.performHapticFeedback(HapticFeedbackType.SegmentTick)
checkedContentColor = MaterialTheme.colorScheme.onPrimary, onSelect(value)
), },
shapes = when (index) { modifier = Modifier.weight(1f).semantics { role = Role.RadioButton },
0 -> ButtonGroupDefaults.connectedLeadingButtonShapes() // Unselected segments need their own tonal fill to read as one group.
options.lastIndex -> ButtonGroupDefaults.connectedTrailingButtonShapes() colors = ToggleButtonDefaults.colors(
else -> ButtonGroupDefaults.connectedMiddleButtonShapes() containerColor = MaterialTheme.colorScheme.secondaryContainer,
}, contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
) { checkedContainerColor = MaterialTheme.colorScheme.primary,
if (checked && mark) { checkedContentColor = MaterialTheme.colorScheme.onPrimary,
Icon(Icons.Rounded.Check, contentDescription = null, modifier = Modifier.size(18.dp)) ),
Spacer(Modifier.width(6.dp)) shapes = when (index) {
0 -> ButtonGroupDefaults.connectedLeadingButtonShapes()
options.lastIndex -> ButtonGroupDefaults.connectedTrailingButtonShapes()
else -> ButtonGroupDefaults.connectedMiddleButtonShapes()
},
contentPadding = PaddingValues(horizontal = PADDING),
) {
if (check) {
Icon(Icons.Rounded.Check, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(6.dp))
}
Text(label, style = style, maxLines = 1, overflow = TextOverflow.Ellipsis)
} }
Text(label, 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]. */ /** M3 search input field on a tonal container; reports every edit through [onQueryChange]. */
@Composable @Composable
fun SearchField(query: String, onQueryChange: (String) -> Unit, placeholder: String, modifier: Modifier = Modifier) { 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.animateColorAsState
import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Animatable
@@ -33,6 +33,8 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch 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 // 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 "Всё сделано". // 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 android.app.Activity
import androidx.compose.foundation.layout.BoxWithConstraints 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.LinearEasing
import androidx.compose.animation.core.animateFloat 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 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.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, // 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. // 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.Box
import androidx.compose.foundation.layout.BoxScope 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 } 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) @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.runtime.mutableIntStateOf
import androidx.compose.foundation.layout.height 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.GroupsResponse
import org.xtimms.psu.schedule.data.SavedGroup import org.xtimms.psu.schedule.data.SavedGroup
import org.xtimms.psu.schedule.data.choiceKey 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) 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.Arrangement
import androidx.compose.foundation.layout.Column 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.Instant
import java.time.ZonedDateTime import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import org.xtimms.psu.schedule.ui.components.Heading
/** "3 изменения". */ /** "3 изменения". */
fun changesLabel(n: Int) = "$n ${plural(n, "изменение", "изменения", "изменений")}" fun changesLabel(n: Int) = "$n ${plural(n, "изменение", "изменения", "изменений")}"

View File

@@ -1,12 +1,11 @@
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @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.animation.AnimatedContent
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowLeft import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowLeft
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.clearAndSetSemantics
@@ -70,7 +69,6 @@ import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember 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.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.delay
import org.xtimms.psu.schedule.data.DayPlace import org.xtimms.psu.schedule.data.DayPlace
import org.xtimms.psu.schedule.data.Lesson import org.xtimms.psu.schedule.data.Lesson
import org.xtimms.psu.schedule.data.Timetable import org.xtimms.psu.schedule.data.Timetable
import org.xtimms.psu.schedule.data.longLabel import org.xtimms.psu.schedule.data.longLabel
import org.xtimms.psu.schedule.data.minutesLabel import org.xtimms.psu.schedule.data.minutesLabel
import org.xtimms.psu.schedule.data.monday 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.plural
import org.xtimms.psu.schedule.data.today import org.xtimms.psu.schedule.data.today
import org.xtimms.psu.schedule.data.weekSpan 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.Duration
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalTime import java.time.LocalTime
import org.xtimms.psu.schedule.ui.AppNav
/** One time slot of a day: parallel lessons of subgroups share it. */ import org.xtimms.psu.schedule.ui.MainViewModel
data class Slot(val date: LocalDate, val start: LocalTime, val end: LocalTime, val lessons: List<Lesson>) import org.xtimms.psu.schedule.ui.MenuButton
import org.xtimms.psu.schedule.ui.components.Banner
fun MainViewModel.slotsOn(t: Timetable, date: LocalDate, subgroup: String?): List<Slot> = import org.xtimms.psu.schedule.ui.components.CenteredEmpty
repository.lessonsOn(t, date, subgroup, hidden) import org.xtimms.psu.schedule.ui.components.ContentColumn
.groupBy { it.start } import org.xtimms.psu.schedule.ui.components.DetailPane
.map { (start, lessons) -> Slot(date, parseTime(start), lessonEnd(lessons.first(), t), lessons) } import org.xtimms.psu.schedule.ui.components.Empty
.sortedBy { it.start } import org.xtimms.psu.schedule.ui.components.Heading
import org.xtimms.psu.schedule.ui.components.ListCard
/** The current time, refreshed every half minute while the screen is shown. */ import org.xtimms.psu.schedule.ui.components.LocalUndo
@Composable import org.xtimms.psu.schedule.ui.components.OverviewSkeleton
fun rememberClock(): LocalTime { import org.xtimms.psu.schedule.ui.components.PaneRow
var clock by remember { mutableStateOf(nowTime()) } import org.xtimms.psu.schedule.ui.components.TimeText
LaunchedEffect(Unit) { import org.xtimms.psu.schedule.ui.components.WeekMark
while (true) { import org.xtimms.psu.schedule.ui.components.appBarColors
delay(30_000) import org.xtimms.psu.schedule.ui.components.navBarPadding
clock = nowTime() import org.xtimms.psu.schedule.ui.components.twoPane
} import org.xtimms.psu.schedule.ui.lesson.AttendanceCard
} import org.xtimms.psu.schedule.ui.lesson.DayPlaces
return clock 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 @Composable
fun HomeScreen(vm: MainViewModel, nav: AppNav) { 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 -> (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)) { Row(Modifier.padding(start = 12.dp)) {
if (pending.size > 4) TextButton(onClick = { expanded = !expanded }) { 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" private fun Slot.timeLabel() = "$start → $end"
/** The next lesson in full: subject, time with a countdown, room and teacher. */ /** 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() .fillMaxWidth()
.morphOrigin("featured|${slot.date}|${lesson.key}|${lesson.start}", MaterialTheme.colorScheme.card, 12.dp) .morphOrigin("featured|${slot.date}|${lesson.key}|${lesson.start}", MaterialTheme.colorScheme.card, 12.dp)
.clip(MaterialTheme.shapes.medium) .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)) SubjectDot(lesson.subject, 14.dp, Modifier.padding(top = 8.dp))
Spacer(Modifier.width(16.dp)) Spacer(Modifier.width(16.dp))
@@ -563,7 +574,7 @@ fun CompactLesson(lesson: Lesson, slot: Slot, onClick: (() -> Unit)?) {
if (onClick != null) Modifier if (onClick != null) Modifier
.morphOrigin("compact|${slot.date}|${lesson.key}|${lesson.start}", MaterialTheme.colorScheme.card, 12.dp) .morphOrigin("compact|${slot.date}|${lesson.key}|${lesson.start}", MaterialTheme.colorScheme.card, 12.dp)
.clip(MaterialTheme.shapes.medium) .clip(MaterialTheme.shapes.medium)
.clickable(onClickLabel = "Подробнее", role = Role.Button, onClick = onClick) .lessonPress(lesson, slot.date, onClick)
else Modifier, else Modifier,
), ),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,

View File

@@ -1,6 +1,6 @@
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) @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.background
import androidx.compose.foundation.layout.Arrangement 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 org.xtimms.psu.schedule.ui.theme.card
import java.time.LocalDate import java.time.LocalDate
import java.time.temporal.ChronoUnit 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. */ /** Upcoming exams of the open group, soonest first; the cache of another group is ignored. */
@Composable @Composable

View File

@@ -1,6 +1,6 @@
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) @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.platform.LocalHapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType 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.nowTime
import org.xtimms.psu.schedule.data.today import org.xtimms.psu.schedule.data.today
import java.time.LocalDate 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. */ /** Attendance marks of the open group, for cards deep in the tree. */
val LocalMarks = staticCompositionLocalOf<List<Mark>> { emptyList() } 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.Arrangement
import androidx.compose.foundation.layout.Column 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.DayPlace
import org.xtimms.psu.schedule.data.Timetable import org.xtimms.psu.schedule.data.Timetable
import java.time.LocalDate 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 // "Занятия в Полоцке": 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. // 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)) Icon(Icons.Rounded.LocationCity, contentDescription = null, modifier = Modifier.size(24.dp))
Spacer(Modifier.width(14.dp)) Spacer(Modifier.width(14.dp))
Column { Column {
Text(p.text, style = MaterialTheme.typography.titleMediumEmphasized) Text(p.label, style = MaterialTheme.typography.titleMediumEmphasized)
Text( Text(
listOfNotNull(p.subgroup, "пометка в Word на этот день").joinToString(" · ").replaceFirstChar { it.titlecase() }, listOfNotNull(p.subgroup, "пометка в Word на этот день").joinToString(" · ").replaceFirstChar { it.titlecase() },
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
@@ -67,7 +68,7 @@ fun DayPlaceMark(places: List<DayPlace>, modifier: Modifier = Modifier) {
shape = MaterialTheme.shapes.extraSmall, shape = MaterialTheme.shapes.extraSmall,
color = MaterialTheme.colorScheme.tertiaryContainer, color = MaterialTheme.colorScheme.tertiaryContainer,
contentColor = MaterialTheme.colorScheme.onTertiaryContainer, 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( Text(
first.place, 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.background
import androidx.compose.foundation.layout.Box 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.material3.MaterialTheme
import androidx.compose.runtime.Composable 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.Timetable
import org.xtimms.psu.schedule.data.parseTime import org.xtimms.psu.schedule.data.parseTime
import java.time.LocalTime 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), * 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) ?: parseTime(lesson.start).plusMinutes(80)
fun Color.luminance(): Float = 0.2126f * red + 0.7152f * green + 0.0722f * blue 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.activity.compose.PredictiveBackHandler
import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween 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.background
import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown 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.Rect
import androidx.compose.ui.geometry.lerp import androidx.compose.ui.geometry.lerp
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.layer.GraphicsLayer import androidx.compose.ui.graphics.layer.GraphicsLayer
import androidx.compose.ui.graphics.layer.drawLayer 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 // 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. // 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. */ /** 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 picture: GraphicsLayer) 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 { enum class MorphKind {
/** A page: the whole screen on a phone, a large panel on a tablet. */ /** 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 @Stable
class MorphHost { class MorphHost {
internal val origins = mutableMapOf<String, MorphOrigin>() 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. // The element the finger went down on, and when: the page grows from what was tapped.
internal var touched: String? = null internal var touched: String? = null
internal var touchedAt = 0L internal var touchedAt = 0L
@@ -90,11 +101,16 @@ class MorphHost {
internal var closing by mutableStateOf(false) internal var closing by mutableStateOf(false)
internal val progress = Animatable(0f) internal val progress = Animatable(0f)
/** Opens [content] growing out of the element tapped a moment ago, if any. */ /** The element tapped a moment ago, if any. */
fun open(kind: MorphKind, content: @Composable (close: () -> Unit) -> Unit) { fun recentTouch(): String? = touched?.takeIf { System.currentTimeMillis() - touchedAt < 1500 }
val key = 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 closing = false
entry = MorphEntry(key, kind, content) entry = MorphEntry(from, kind, content)
} }
fun close() { fun close() {
@@ -105,17 +121,18 @@ class MorphHost {
val LocalMorph = staticCompositionLocalOf<MorphHost?> { null } 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 * Marks a card or a button as a place a page or a sheet grows from: its fill [color], corner
* element itself is hidden: the page has taken its place. * [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 @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 host = LocalMorph.current ?: return this
val picture = rememberGraphicsLayer() val picture = rememberGraphicsLayer()
DisposableEffect(host, key) { onDispose { host.origins.remove(key) } } DisposableEffect(host, key) { onDispose { host.origins.remove(key) } }
val hidden = host.entry?.key == key val hidden = host.entry?.key == key
return this 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) { .pointerInput(host, key) {
awaitEachGesture { awaitEachGesture {
awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial) 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): * Marks what is inside an origin with the same [key] (a button's icon, a card's text): the
* the origin's picture fades out over the first third, the content fades in after it. Back, * container draws this picture instead of the whole element, on its own fill and shadow.
* a tap on the scrim or the predictive back gesture shrink it back the same way. */
@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 @Composable
fun MorphOverlay(host: MorphHost) { fun MorphOverlay(host: MorphHost) {
@@ -150,7 +188,11 @@ fun MorphOverlay(host: MorphHost) {
} }
LaunchedEffect(host.closing) { LaunchedEffect(host.closing) {
if (!host.closing) return@LaunchedEffect 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.entry = null
host.closing = false host.closing = false
} }
@@ -183,6 +225,7 @@ fun MorphOverlay(host: MorphHost) {
} }
} }
val origin = entry.key?.let { host.origins[it] } 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 from = origin?.bounds?.translate(-rootOffset) ?: Rect(target.center, 0f).inflate(minOf(target.width, target.height) * 0.3f)
val p = host.progress.value val p = host.progress.value
val rect = lerp(from, target, p) val rect = lerp(from, target, p)
@@ -191,9 +234,16 @@ fun MorphOverlay(host: MorphHost) {
wide -> 28.dp wide -> 28.dp
else -> 0.dp else -> 0.dp
} }
val radius = lerp(origin?.radius ?: 28.dp, targetRadius, p) // The thresholds of MDC's container transform, as fractions of this way (p) and back.
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)) 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) 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. // The scrim; a tap on it closes.
Box( Box(
Modifier Modifier
@@ -203,22 +253,35 @@ fun MorphOverlay(host: MorphHost) {
) )
Layout( Layout(
content = { content = {
// The content, laid out once at its final size, revealed by the growing bounds. // The card's own picture underneath, growing with the container.
Box(Modifier.graphicsLayer { alpha = ((p - 0.3f) / 0.7f).coerceIn(0f, 1f) }) { 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 (entry.kind == MorphKind.Sheet) SheetChrome(onDrag = { drag = (drag + it).coerceAtLeast(0f) }, onRelease = {
if (drag > with(density) { 120.dp.toPx() }) host.close() else drag = 0f if (drag > with(density) { 120.dp.toPx() }) host.close() else drag = 0f
}) { entry.content(host::close) } }) { entry.content(host::close) }
else 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 modifier = Modifier
.offset { IntOffset(rect.left.roundToInt(), rect.top.roundToInt()) } .offset { IntOffset(rect.left.roundToInt(), rect.top.roundToInt()) }
@@ -228,16 +291,18 @@ fun MorphOverlay(host: MorphHost) {
scaleY = s scaleY = s
translationY = drag 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) .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. // Taps inside the page never reach the scrim under it.
.pointerInput(Unit) { detectTapGestures { } }, .pointerInput(Unit) { detectTapGestures { } },
) { measurables, _ -> ) { measurables, _ ->
val content = measurables[0].measure(Constraints.fixed(target.width.roundToInt(), target.height.roundToInt())) val picture = if (origin != null) measurables[0].measure(Constraints.fixed(rect.width.roundToInt().coerceAtLeast(0), rect.height.roundToInt().coerceAtLeast(0))) else null
val picture = measurables.getOrNull(1)?.measure(Constraints.fixed(rect.width.roundToInt().coerceAtLeast(0), rect.height.roundToInt().coerceAtLeast(0))) val content = measurables.last().measure(Constraints.fixed(target.width.roundToInt(), target.height.roundToInt()))
layout(rect.width.roundToInt().coerceAtLeast(0), rect.height.roundToInt().coerceAtLeast(0)) { 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) 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.ui.Modifier
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme

View File

@@ -1,6 +1,6 @@
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @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.launch
import kotlinx.coroutines.delay 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.today
import org.xtimms.psu.schedule.data.weekdayName import org.xtimms.psu.schedule.data.weekdayName
import java.time.LocalDate 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. */ /** Tasks and notes of the open group, for cards deep in the tree. */
val LocalNotes = staticCompositionLocalOf<List<Note>> { emptyList() } val LocalNotes = staticCompositionLocalOf<List<Note>> { emptyList() }
@@ -118,15 +132,48 @@ fun MainViewModel.nextOccurrence(t: Timetable, subject: String, after: LocalDate
.firstOrNull() .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 @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() val overdue = note.overdue()
var viewing by remember { mutableStateOf(false) } var viewing by remember { mutableStateOf(false) }
val haptic = LocalHapticFeedback.current val haptic = LocalHapticFeedback.current
// A ticked task is struck through in place first, then moves down to the done ones. // 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) } var shownDone by remember(note.id, note.done) { mutableStateOf(note.done) }
val scope = rememberCoroutineScope() 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( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier modifier = Modifier
@@ -135,14 +182,7 @@ fun NoteRow(note: Note, onToggle: () -> Unit, onClick: () -> Unit, showSubject:
.clickable(onClickLabel = "Изменить", role = Role.Button, onClick = onClick) .clickable(onClickLabel = "Изменить", role = Role.Button, onClick = onClick)
.padding(start = 8.dp, end = 16.dp, top = 2.dp, bottom = 2.dp), .padding(start = 8.dp, end = 16.dp, top = 2.dp, bottom = 2.dp),
) { ) {
if (note.task) Checkbox(checked = shownDone, onCheckedChange = { done -> if (note.task) Checkbox(checked = shownDone, onCheckedChange = ::check)
haptic.performHapticFeedback(if (done) HapticFeedbackType.Confirm else HapticFeedbackType.ToggleOff)
shownDone = done
if (done) scope.launch {
delay(DONE_PAUSE)
onToggle()
} else onToggle()
})
else Box(Modifier.size(48.dp), contentAlignment = Alignment.Center) { else Box(Modifier.size(48.dp), contentAlignment = Alignment.Center) {
Icon(Icons.Rounded.StickyNote2, contentDescription = "Заметка", tint = MaterialTheme.colorScheme.onSurfaceVariant) Icon(Icons.Rounded.StickyNote2, contentDescription = "Заметка", tint = MaterialTheme.colorScheme.onSurfaceVariant)
} }
@@ -173,19 +213,10 @@ fun NoteRow(note: Note, onToggle: () -> Unit, onClick: () -> Unit, showSubject:
} }
// The first photo, with how many more; it opens the viewer, the row the editor. // 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)) { if (note.photos.isNotEmpty()) Box(Modifier.padding(start = 12.dp)) {
PhotoThumb(note.photos.first(), 48.dp, onClick = { viewing = true }) PhotoThumb(note.photos.first(), 48.dp, onClick = { viewing = true }, more = note.photos.size - 1)
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),
)
} }
} }
}
if (viewing) PhotoViewer(note.photos, 0) { viewing = false } if (viewing) PhotoViewer(note.photos, 0) { viewing = false }
} }
@@ -350,7 +381,7 @@ fun LessonNotes(vm: MainViewModel, lesson: Lesson, date: LocalDate) {
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
notes.forEach { note -> 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)) { FilledTonalButton(onClick = { adding = true }, modifier = Modifier.padding(top = 8.dp)) {
Icon(Icons.Rounded.Add, contentDescription = null, modifier = Modifier.size(18.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) @file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
package org.xtimms.psu.schedule.ui package org.xtimms.psu.schedule.ui.onboarding
import android.Manifest import android.Manifest
import android.os.Build 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.WeekColors
import org.xtimms.psu.schedule.ui.theme.page import org.xtimms.psu.schedule.ui.theme.page
import kotlin.coroutines.cancellation.CancellationException 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. */ /** 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 } private enum class Step { Welcome, Group, Subgroup, Choice, Foreign, Reminders }

View File

@@ -1,6 +1,6 @@
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @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.animation.AnimatedContent
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
@@ -101,6 +101,44 @@ import java.time.LocalDate
import java.time.YearMonth import java.time.YearMonth
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.util.Locale 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 val MONTH = DateTimeFormatter.ofPattern("LLLL yyyy", Locale.forLanguageTag("ru"))
private const val MONTHS_BEFORE = 2 private const val MONTHS_BEFORE = 2
@@ -318,8 +356,8 @@ fun CalendarScreen(vm: MainViewModel, nav: AppNav) {
modifier = Modifier modifier = Modifier
.align(Alignment.BottomEnd) .align(Alignment.BottomEnd)
.padding(end = 16.dp, bottom = 16.dp + navBarPadding()) .padding(end = 16.dp, bottom = 16.dp + navBarPadding())
.morphOrigin("fab|calendar", MaterialTheme.colorScheme.primaryContainer, 16.dp), .morphOrigin("fab|calendar", MaterialTheme.colorScheme.primaryContainer, 16.dp, elevation = 6.dp),
) { Icon(Icons.Rounded.Add, contentDescription = "Новый план") } ) { Icon(Icons.Rounded.Add, contentDescription = "Новый план", modifier = Modifier.morphContent("fab|calendar")) }
} }
target?.let { tg -> target?.let { tg ->

View File

@@ -1,6 +1,6 @@
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @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.shrinkVertically
import androidx.compose.animation.fadeOut 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.card
import org.xtimms.psu.schedule.ui.theme.page import org.xtimms.psu.schedule.ui.theme.page
import java.time.LocalDate 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 октября". */ /** "Сегодня • Суббота • 17 октября", "Завтра • …" or "Понедельник • 19 октября". */
private fun dayTitle(d: LocalDate): String = when (d) { 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 } 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()) { BoxWithConstraints(Modifier.fillMaxSize()) {
@@ -285,8 +317,8 @@ fun PlansScreen(vm: MainViewModel, nav: AppNav) {
modifier = Modifier modifier = Modifier
.align(Alignment.BottomEnd) .align(Alignment.BottomEnd)
.padding(end = 16.dp, bottom = 16.dp + navBarPadding()) .padding(end = 16.dp, bottom = 16.dp + navBarPadding())
.morphOrigin("fab|plans", MaterialTheme.colorScheme.primaryContainer, 16.dp), .morphOrigin("fab|plans", MaterialTheme.colorScheme.primaryContainer, 16.dp, elevation = 6.dp),
) { Icon(Icons.Rounded.Add, contentDescription = "Новое задание") } ) { Icon(Icons.Rounded.Add, contentDescription = "Новое задание", modifier = Modifier.morphContent("fab|plans")) }
} }
if (tuning) ModalBottomSheet(onDismissRequest = { tuning = false }) { PlansSettings(prefs, vm::setPlans) } if (tuning) ModalBottomSheet(onDismissRequest = { tuning = false }) { PlansSettings(prefs, vm::setPlans) }
@@ -332,6 +364,7 @@ private fun PlanList(
rows: List<Row>, rows: List<Row>,
onToggle: (String) -> Unit, onToggle: (String) -> Unit,
onEdit: (Note) -> Unit, onEdit: (Note) -> Unit,
onPostpone: (Note, UndoHost?) -> Unit,
color: Color = MaterialTheme.colorScheme.surface, color: Color = MaterialTheme.colorScheme.surface,
) { ) {
if (rows.isEmpty()) { if (rows.isEmpty()) {
@@ -356,7 +389,14 @@ private fun PlanList(
} }
is Row.Item -> item(key = "n${row.note.id}") { is Row.Item -> item(key = "n${row.note.id}") {
Box(Modifier.animateItem()) { 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") { is Row.Divider -> item(key = "d$day") {
@@ -471,7 +511,8 @@ private fun Board(
color = MaterialTheme.colorScheme.card, color = MaterialTheme.colorScheme.card,
modifier = Modifier.fillMaxWidth().border(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), MaterialTheme.shapes.large), 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) @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 kotlinx.coroutines.flow.drop
import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.unit.Dp
import androidx.compose.animation.togetherWith import androidx.compose.animation.togetherWith
import androidx.compose.animation.Crossfade import androidx.compose.animation.Crossfade
import androidx.compose.animation.shrinkVertically import androidx.compose.animation.shrinkVertically
@@ -16,29 +14,16 @@ import androidx.compose.animation.expandVertically
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.ui.graphics.RectangleShape 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.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 org.xtimms.psu.schedule.data.hiddenIn
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import kotlin.math.abs import kotlin.math.abs
import androidx.compose.material.icons.rounded.Weekend 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.background
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.material.icons.rounded.Today 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.foundation.layout.Column 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.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer 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.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.CloudOff 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.Refresh
import androidx.compose.material.icons.rounded.CalendarViewWeek import androidx.compose.material.icons.rounded.CalendarViewWeek
import androidx.compose.material.icons.rounded.ViewDay import androidx.compose.material.icons.rounded.ViewDay
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.material.icons.rounded.Schedule
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -106,7 +77,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.xtimms.psu.schedule.data.Bell
import org.xtimms.psu.schedule.data.DayPlace import org.xtimms.psu.schedule.data.DayPlace
import org.xtimms.psu.schedule.data.Lesson import org.xtimms.psu.schedule.data.Lesson
import org.xtimms.psu.schedule.data.Timetable 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.monday
import org.xtimms.psu.schedule.data.parseDate import org.xtimms.psu.schedule.data.parseDate
import org.xtimms.psu.schedule.data.today import org.xtimms.psu.schedule.data.today
import org.xtimms.psu.schedule.data.withPlace
import java.time.Duration import java.time.Duration
import java.time.LocalDate 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) @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
@@ -470,7 +471,8 @@ private fun DayPage(
if (!isToday) return@LaunchedEffect if (!isToday) return@LaunchedEffect
val now = slots.indexOfFirst { clock.isBefore(it.end) } val now = slots.indexOfFirst { clock.isBefore(it.end) }
if (now <= 0) return@LaunchedEffect 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) list.scrollToItem(before)
} }
ContentColumn { ContentColumn {
@@ -487,7 +489,11 @@ private fun DayPage(
} }
// "Занятия в Полоцке": the day's town before its lessons. // "Занятия в Полоцке": the day's town before its lessons.
if (places.isNotEmpty()) item(key = "places") { AnimatedItem { DayPlaces(places) } } 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 running = isToday && !clock.isBefore(slot.start) && clock.isBefore(slot.end)
val status = when { val status = when {
running -> "Идёт · ещё ${minutesLabel(Duration.between(clock, slot.end).toMinutes())}" 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. */ /** When the week table has no semester for this schedule: the Word rows by weekday. */
@Composable @Composable
private fun WordRows(t: Timetable, subgroup: String?, hidden: Set<String>, onOpen: (Lesson) -> Unit) { 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.BorderStroke
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -49,6 +49,19 @@ import org.xtimms.psu.schedule.data.weekdayName
import java.time.Duration import java.time.Duration
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalTime 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 // 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, // 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, contentColor = color.onSoft,
modifier = modifier.morphOrigin("stand|${slot.date}|${lesson.key}|${lesson.start}", color.soft, 28.dp), 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) { Row(verticalAlignment = Alignment.CenterVertically) {
TimeText( TimeText(
"${slot.start} → ${slot.end}" + (lesson.pair?.let { " · $it пара" } ?: ""), "${slot.start} → ${slot.end}" + (lesson.pair?.let { " · $it пара" } ?: ""),

View File

@@ -1,6 +1,6 @@
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) @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.runtime.getValue
import androidx.compose.animation.core.tween 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 org.xtimms.psu.schedule.data.withPlace
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalTime 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 HOUR = 60.dp
private val GUTTER = 36.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) { private fun LessonBlock(lesson: Lesson, slot: Slot, wide: Boolean, notes: Int, onClick: () -> Unit, modifier: Modifier) {
val color = subjectColor(lesson.subject) val color = subjectColor(lesson.subject)
val session = lesson.session 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() 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. // A narrow column keeps the room whole: the day's town is under its date.
val room = when { 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() } else -> rooms.ifEmpty { lesson.place.orEmpty() }
} }
Surface( Surface(
onClick = onClick,
shape = MaterialTheme.shapes.small, shape = MaterialTheme.shapes.small,
color = color.container, color = color.container,
contentColor = color.content, contentColor = color.content,
border = session?.let { androidx.compose.foundation.BorderStroke(2.dp, it.outline()) }, 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}" + contentDescription = "${lesson.subject}, ${slot.start}–${slot.end}" +
(if (room.isNotEmpty()) ", ауд. $room" else "") + (lesson.type.takeIf { it.isNotEmpty() }?.let { ", $it" } ?: "") + (if (room.isNotEmpty()) ", ауд. $room" else "") + (lesson.type.takeIf { it.isNotEmpty() }?.let { ", $it" } ?: "") +
(if (notes > 0) ", заданий и заметок: $notes" else "") (if (notes > 0) ", заданий и заметок: $notes" else "")
}, },
) { ) { Box(Modifier.fillMaxSize().morphContent(morphKey)) {
// A small dot in the corner marks lessons with open tasks or notes. // 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) { if (notes > 0) Box(Modifier.fillMaxSize().padding(5.dp), contentAlignment = Alignment.TopEnd) {
Box(Modifier.size(8.dp).background(color.content, CircleShape)) 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) 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) @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.runtime.mutableIntStateOf
import androidx.compose.ui.graphics.graphicsLayer 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.monday
import org.xtimms.psu.schedule.data.today import org.xtimms.psu.schedule.data.today
import java.time.LocalDate 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 { private sealed interface Target {
data class Teacher(val name: String) : Target data class Teacher(val name: String) : Target
data class Room(val room: 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() internal fun startDate(): LocalDate = if (today().dayOfWeek.value == 7) today().plusDays(1) else today()
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class, ExperimentalLayoutApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class, ExperimentalLayoutApi::class)
@Composable @Composable
fun SearchScreen(vm: MainViewModel, nav: AppNav) { fun SearchScreen(vm: MainViewModel, nav: AppNav) {
val timetable by vm.timetable.collectAsStateWithLifecycle() val timetable by vm.timetable.collectAsStateWithLifecycle()
val settings by vm.settings.collectAsStateWithLifecycle()
val favorites = settings?.searchFavorites.orEmpty()
var query by rememberSaveable { mutableStateOf("") } var query by rememberSaveable { mutableStateOf("") }
var target by remember { mutableStateOf<Target?>(null) } var target by remember { mutableStateOf<Target?>(null) }
var date by remember { mutableStateOf(startDate()) } var date by remember { mutableStateOf(startDate()) }
@@ -106,6 +165,8 @@ fun SearchScreen(vm: MainViewModel, nav: AppNav) {
delay(250) delay(250)
attempt { vm.api.searchDirectory(query.trim()) }.onSuccess { hits = it; error = null }.onFailure { error = it.message } 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) { LaunchedEffect(target, date, retries) {
val t = target ?: return@LaunchedEffect val t = target ?: return@LaunchedEffect
loading = true loading = true
@@ -180,6 +241,7 @@ fun SearchScreen(vm: MainViewModel, nav: AppNav) {
if (dated && date != startDate()) IconButton(onClick = { date = startDate() }) { if (dated && date != startDate()) IconButton(onClick = { date = startDate() }) {
Icon(Icons.Rounded.Today, contentDescription = "Сегодня") 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) { if (target == null || pane) {
item { SearchField(query, { query = it }, "Фамилия или номер аудитории") } item { SearchField(query, { query = it }, "Фамилия или номер аудитории") }
val h = hits val h = hits
if (h == null && !pane) item { if (h == null) {
Box(Modifier.fillParentMaxHeight(0.75f).fillMaxWidth(), contentAlignment = Alignment.Center) { Empty( // 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)
icon = Icons.Rounded.PersonSearch, val open: (Target) -> Unit = { target = it; date = startDate() }
) } if (starred.isNotEmpty()) item(key = "starred") {
} else if (h != null) { 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.teachers.isNotEmpty()) item { if (h.teachers.isNotEmpty()) item {
ListCard(Icons.Rounded.Person, "Преподаватели") { ListCard(Icons.Rounded.Person, "Преподаватели") {
h.teachers.forEachIndexed { index, t -> h.teachers.forEachIndexed { index, t ->
@@ -235,6 +310,7 @@ fun SearchScreen(vm: MainViewModel, nav: AppNav) {
} }
} }
val dayList: @Composable (Modifier) -> Unit = { modifier -> val dayList: @Composable (Modifier) -> Unit = { modifier ->
val clock = rememberClock()
LazyColumn( LazyColumn(
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 24.dp + navBarPadding()), contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 24.dp + navBarPadding()),
verticalArrangement = Arrangement.spacedBy(12.dp), 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 { 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( LessonCard(
Lesson( Lesson(
key = l.start + l.subject, key = l.start + l.subject,
@@ -269,6 +351,13 @@ fun SearchScreen(vm: MainViewModel, nav: AppNav) {
), ),
time = l.start + (l.end?.let { " → $it" } ?: ""), time = l.start + (l.end?.let { " → $it" } ?: ""),
pair = bells.firstOrNull { it.start == l.start }?.pair, 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, onClick = null,
) } ) }
} }
@@ -323,6 +412,7 @@ fun SearchScreen(vm: MainViewModel, nav: AppNav) {
if (date != startDate()) IconButton(onClick = { date = startDate() }) { if (date != startDate()) IconButton(onClick = { date = startDate() }) {
Icon(Icons.Rounded.Today, contentDescription = "Сегодня") Icon(Icons.Rounded.Today, contentDescription = "Сегодня")
} }
FavoriteButton(t.key in favorites) { vm.toggleSearchFavorite(t.key) }
IconButton(onClick = { target = null; day = null }) { IconButton(onClick = { target = null; day = null }) {
Icon(Icons.Rounded.Close, contentDescription = "Закрыть") Icon(Icons.Rounded.Close, contentDescription = "Закрыть")
} }
@@ -411,6 +501,49 @@ private const val SPAN = 52
@Composable @Composable
private fun SectionTitle(text: String) = Heading(text) 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) @OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
private fun RoomChips(rooms: List<String>, onClick: (String) -> Unit) { private fun RoomChips(rooms: List<String>, onClick: (String) -> Unit) {

View File

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

View File

@@ -1,6 +1,6 @@
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @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.layout.widthIn
import androidx.compose.foundation.lazy.staggeredgrid.items 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.today
import org.xtimms.psu.schedule.data.weekdayName import org.xtimms.psu.schedule.data.weekdayName
import java.time.LocalTime 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. */ /** Everything the loaded weeks say about one subject. */
private class SubjectSummary( private class SubjectSummary(
@@ -406,7 +434,7 @@ private fun SubjectNotes(s: SubjectSummary, vm: MainViewModel) {
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, 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 }) { if (next != null) FilledTonalButton(onClick = { adding = true }) {
Icon(Icons.Rounded.Add, contentDescription = null, modifier = Modifier.size(18.dp)) Icon(Icons.Rounded.Add, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.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.plural
import org.xtimms.psu.schedule.data.shortWeekday import org.xtimms.psu.schedule.data.shortWeekday
import org.xtimms.psu.schedule.data.today 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 import java.time.LocalDate
/** A day of the week as the widget shows it. */ /** 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. // "Занятия в Полоцке": a day of a group or subgroup in another town, not a lesson.
const placeLine = (p) => const placeLine = (p) =>
[ [
`@ ${p.date || DAYS[p.day]}`, `@ ${p.date || DAYS[p.day]}${p.from ? ` с ${p.from}` : ""}`,
p.text, p.text,
[...p.groups, ...p.subgroups.map((s) => `${s.group}/${s.label.replace("Подгруппа ", "п")}`)].join(","), [...p.groups, ...p.subgroups.map((s) => `${s.group}/${s.label.replace("Подгруппа ", "п")}`)].join(","),
].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 // 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. // 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. // The longest semester has about twenty weeks; "27н." is a typo, not a week.
const LAST_WEEK = 24; 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 { roomMoves, onlyPlace, weekPlan, partsByWeek } from "./rooms.js";
import { placeNote, lessonPlace, placeOf } from "./places.js"; import { placeNote, lessonPlace, placeOf } from "./places.js";
const run = promisify(execFile); const run = promisify(execFile);
export const PARSER_VERSION = 18; export const PARSER_VERSION = 19;
const days = [ const days = [
"понедельник", "понедельник",
"вторник", "вторник",
@@ -20,10 +20,17 @@ const days = [
"суббота", "суббота",
"воскресенье", "воскресенье",
]; ];
const clean = (value) => export const clean = (value) =>
value value
.replace(/\u00a0/g, " ") .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(/[\t ]+/g, " ")
.replace(/«\s+/g, "«")
.replace(/\s+»/g, "»")
.replace(/\s*\n\s*/g, "\n") .replace(/\s*\n\s*/g, "\n")
.trim(); .trim();
// A group is a two-digit year and a capitalised abbreviation: "23-БА", "24-ДОи", "23-ИНС-1", // 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, date = null,
time = null, time = null,
pair = 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. // Session schedules use separate room, subject and teacher columns.
const session = rows[header].some((cell) => /пара/i.test(cell.text)); const session = rows[header].some((cell) => /пара/i.test(cell.text));
let sessionColumns = null; let sessionColumns = null;
@@ -625,6 +634,7 @@ export function parseDocx(buffer, source = {}) {
: null; : null;
time = null; time = null;
pair = null; pair = null;
dayStart = null;
} }
if (!day) continue; if (!day) continue;
// Credits and exams give only the start ("8.30"); the bell table supplies the end. // 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; pair = session ? row.find((c) => c.col === 1)?.text : null;
} }
if (!time) continue; if (!time) continue;
dayStart ??= time.start;
seenRows++; seenRows++;
const continuedTime = const continuedTime =
row.find((c) => c.col === timeCol)?.merge === "continue"; 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. // are in that town. A lesson may follow it in the same cell.
const note = placeNote(raw); const note = placeNote(raw);
if (note) { 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; raw = note.rest;
if (!raw.trim()) return; if (!raw.trim()) return;
} }

View File

@@ -64,7 +64,12 @@ export function placeOf(lesson, places, labelsOf) {
let found = null; let found = null;
for (const group of lesson.groups) { for (const group of lesson.groups) {
const notes = places.filter( 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; if (!notes.length) return null;
const label = lesson.subgroups.find((s) => s.group === group)?.label; 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) { export function placesOn(places, group, labels, day, date = null) {
const on = (places || []) const on = (places || [])
.filter((p) => p.groups.includes(group) && p.day === day && (!p.date || p.date === date)) .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); const whole = on.filter((p) => !p.subgroup);
for (const p of on) for (const p of on)
if ( if (

View File

@@ -1,6 +1,7 @@
import { test } from "node:test"; import { test } from "node:test";
import assert from "node:assert/strict"; 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 { roomMoves, weekPlan, onlyPlace } from "../server/rooms.js";
import { peopleAndRooms, roomsOn, teachersOn, movedOn } from "../server/directory.js"; import { peopleAndRooms, roomsOn, teachersOn, movedOn } from "../server/directory.js";
@@ -161,6 +162,20 @@ test("Вид занятия после преподавателя: фамили
assert.equal(details("(л.з.) Алексеева Т.А. ауд. 33н").subject, "(л.з.) Алексеева Т.А. ауд. 33н"); 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 () => { test("Старое имя разделённой группы: понятная ошибка, другое написание — та же группа", async () => {
const { timetable } = await import("../server/timetable.js"); const { timetable } = await import("../server/timetable.js");
const schedule = { groups: ["24-ИЯ (РА)и-1", "24-ИЯ (РА)и-2"], lessons: [], notes: [], period: "Осенний семестр", source: {} }; 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 вт 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
вт 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-ИТ-1,24-ИТ-1/п3
пн 08:30–09:55 | каждую неделю, верхняя строка | Объектно-ориентированные технологии програм-мирования и стандарты проектирования | Лабораторная | 311А | Гордевич Д.А. | 24-ИТ-2,24-ИТ-2/п3 пн 08:30–09:55 | каждую неделю, верхняя строка | Объектно-ориентированные технологии програм-мирования и стандарты проектирования | Лабораторная | 311А | Гордевич Д.А. | 24-ИТ-2,24-ИТ-2/п3
пн 08:30–09:55 | каждую неделю, нижняя строка | Системное программирование | Лабораторная | 207Г | Юрчишко Е.И. | 24-ИТ-1,24-ИТ-1/п4 пн 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 | даты 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-ИТ-1
сб 11:45–13:10 | каждую неделю, верхняя строка | Основы информационной безопасности | Практика | 321Д | Пастухов Ю.Ф. | 24-ИТ-2 сб 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-ИТ-1,24-ИТ-1/п2
чт 08:30–09:55 | каждую неделю, нижняя строка | Системное программирование | Лабораторная | 311А | Скуковская А.А. | 24-ИТ-2,24-ИТ-2/п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-ИТ-1,24-ИТ-1/п2
чт 11:45–13:10 | каждую неделю, верхняя строка | Базы данных | Лабораторная | 218Д | Дьякова А.С. | 24-ИТ-2,24-ИТ-2/п2 чт 11:45–13:10 | каждую неделю, верхняя строка | Базы данных | Лабораторная | 218Д | Дьякова А.С. | 24-ИТ-2,24-ИТ-2/п2
чт 11:45–13:10 | каждую неделю, верхняя строка | Веб-технологии | Лабораторная | 207Г | Аврусевич К.Д. | 24-ИТ-1,24-ИТ-1/п4 чт 11:45–13:10 | каждую неделю, верхняя строка | Веб-технологии | Лабораторная | 207Г | Аврусевич К.Д. | 24-ИТ-1,24-ИТ-1/п4