feat: Add skeleton loading UI components for various screens

- Implemented `Skeleton.kt` for loading placeholders in the app, including lesson cards, schedule, overview, and subjects.
- Introduced `Undo.kt` to manage undo actions with a snackbar interface.
- Created `WeekWidget.kt` for a widget displaying the week's schedule, including dynamic updates based on the current timetable.
- Added various splash animations in the `animator` directory for a smooth launch experience.
- Designed drawable resources for the widget and splash screen, including icons for city and room moves.
- Defined XML resources for widget configuration and photo paths.
This commit is contained in:
2026-10-23 21:56:07 +03:00
parent a3df53713f
commit b9c1f63301
59 changed files with 194390 additions and 178421 deletions

View File

@@ -173,6 +173,23 @@ npm run sync
- В документах «Введение в высшее образование» строка «г. Полоцк, Дом офицеров» даёт занятию город, а место проведения остаётся в тексте. - В документах «Введение в высшее образование» строка «г. Полоцк, Дом офицеров» даёт занятию город, а место проведения остаётся в тексте.
- Какие заметки показать над днём группы, решает общий модуль `shared/places.js`: им пользуются и `/api/timetable`, и PWA. Одинаковая заметка во всех подгруппах сводится к одной для всей группы, а заметка своей подгруппы показывается как заметка всего дня. - Какие заметки показать над днём группы, решает общий модуль `shared/places.js`: им пользуются и `/api/timetable`, и PWA. Одинаковая заметка во всех подгруппах сводится к одной для всей группы, а заметка своей подгруппы показывается как заметка всего дня.
**Занятия на выбор** (парсер 16). У 23‑, 24‑ и 25‑РГФ‑1 одна ячейка на всю группу содержит две пары сразу: «Немецкий язык (п.з) – Найверт Ю.О. 208А, / Французский язык (п.з.) – Глобенко М.В. 336Е». Это не подгруппы (ячейка не делится пополам): часть группы учит немецкий, часть французский. Так же записаны «Белорусский язык» и «Стилистика русского языка» у 26‑РГФ и две дисциплины по выбору у 23‑ИТ.
- Ячейка, где каждая строка — целая пара (предмет, вид в скобках, преподаватель), делится на отдельные занятия со своими преподавателем и аудиторией (`parallel` в `server/parser.js`). Строка без предмета («321Д») остаётся при паре над ней. До версии 16 такая ячейка была одной парой «Немецкий язык» с двумя преподавателями и аудиторией французского.
- Каждое такое занятие получает `option` — свой предмет, а документ — `choices` (`{groups, options}`). Тот же предмет в других ячейках этих групп («Немецкий язык» в четверг без французского) — тот же вариант и тоже получает `option`.
- `/api/timetable` (API 15) отдаёт у занятия `option`, а у группы — `choices`: `[["Немецкий язык", "Французский язык"]]`. Клиент, который о них не знает, просто показывает обе пары.
**Пары для иностранных студентов и для остальных** (парсер 17). «Русский язык как иностранный» и пары с пометкой «(для иностранных студентов)», «(для иностр.студ.)» — только для иностранцев, пары «(кроме иностр.студ.)» — только для остальных. Занятие получает `audience`: `"foreign"` или `"local"`.
- Пометка — не часть названия и аудитории: раньше «ауд.36н (для иностранных студентов)» давало аудиторию «36н(дляиностранныхстудентов)».
- В одной ячейке часто две пары сразу: иностранцы на РКИ, остальные на физкультуре или другом занятии. Такая ячейка делится на две, у каждой свой `audience`:
- «Ф и з и ч е с к а я к у л ь т у р а / Русский язык как иностранный (для иностранных студентов) …» (25‑ГЕО, 25‑КБ, 25‑ЭТ, 24‑СК, 24‑ЭБ);
- «Русский язык как иностранный (п.з.) Немирская В.А. 331Е ФИЗИЧЕСКАЯ КУЛЬТУРА» (25‑ и 26‑РГФ): раньше физкультура пропадала, а у РКИ не было аудитории;
- «3‑14н. Великая Отечественная война … 220Д / Русский язык как иностранный … 201Г»: раньше одна пара с аудиторией РКИ.
- «24‑КФ: Русский язык как иностранный …» в ячейке на 24‑КБ и 24‑КФ — занятие только 24‑КФ.
- Город или время строкой выше («17.00 Новополоцк») и преподаватель строкой ниже — не отдельная пара.
- Две пары, обе с пометкой («Теория языка (кроме иностр.студ.)» и «История языка (для иностр.студ.)» у 24‑РГФ‑1), — не выбор предмета, а пары для разных студентов.
- В группах иностранцев («25‑ДОи», «24‑ТНГи‑2») РКИ — пара для всех, `audience` у неё нет.
- `/api/timetable` отдаёт у занятия `audience`, а у группы — `audience: true`, если в ней есть такие пары.
Для старых `.doc` на сервере нужен LibreOffice Writer (`soffice` в PATH или `LIBREOFFICE_BIN`). Конвертация может изменить сложное форматирование, результат нужно сверить. Реальные документы выбранного источника — `.docx`; поддержка `.doc` проверяется отдельно на образце, преобразованном LibreOffice. Для старых `.doc` на сервере нужен LibreOffice Writer (`soffice` в PATH или `LIBREOFFICE_BIN`). Конвертация может изменить сложное форматирование, результат нужно сверить. Реальные документы выбранного источника — `.docx`; поддержка `.doc` проверяется отдельно на образце, преобразованном LibreOffice.
PDF, Excel, изображения и сканы пока не импортируются. Они не выдаются за распознанное расписание. PDF, Excel, изображения и сканы пока не импортируются. Они не выдаются за распознанное расписание.
@@ -244,9 +261,22 @@ DOMAIN=schedule.example.by docker compose --profile https up -d --build
Проверено на эмуляторе Pixel 10 Pro Fold (`OPENED`, `HALF_OPENED` в портрете и в альбомной ориентации) и на AVD «6.7in Foldable» (флип: телефонный макет). Универсальный AVD флипа сообщает приложению сгиб всегда в состоянии `FLAT`, поэтому «ноутбук» на флипе проверен только на фолде. Проверено на эмуляторе Pixel 10 Pro Fold (`OPENED`, `HALF_OPENED` в портрете и в альбомной ориентации) и на AVD «6.7in Foldable» (флип: телефонный макет). Универсальный AVD флипа сообщает приложению сгиб всегда в состоянии `FLAT`, поэтому «ноутбук» на флипе проверен только на фолде.
- **Запуск.** Заставка (`core-splashscreen`, одинаково на Android 8–16): на фоне приложения рисуется иконка — календарь вырастает, кольца опускаются, зелёная галочка дорисовывается (около 750 мс, на Android 12+ анимированно). Заставка держится, пока не прочитаны сохранённые настройки, поэтому пустого кадра нет. Потом иконка раздувается и растворяется, а карточки «Обзора» поднимаются одна за другой (один раз за запуск).
- **Отмена вместо подтверждений.** Удалённое задание или заметка и скрытая пара сразу пропадают, а снизу 10 секунд висит «Задание удалено · Вернуть» или «Скрыто: «…» · Вернуть». Фото удалённого задания стираются, только когда вернуть уже нельзя. В подробностях пары и в листе предмета snackbar показывается прямо в них.
- **Сегодня — на текущей паре.** Страница сегодняшнего дня открывается на идущей паре (или следующей), а не на первой утренней.
- **Виброотклик:** тик при выборе дня в ленте и при листании дней пальцем, при выборе в кнопках-переключателях. Подтверждение при выполнении задания и отметке посещения, щелчок у переключателей настроек.
- **Скелетоны вместо спиннера:** пока грузится первое расписание группы, «Обзор», «Расписание» и «Предметы» показывают серые заготовки в форме своих карточек с мягким бликом. Так же выглядят занятия преподавателя или аудитории в поиске и каталог групп. Для TalkBack это одно «Загрузка». При системной настройке «Убрать анимацию» блик, появление карточек и прочие украшения отключаются.
- **Карточка вырастает в подробности** (container transform). Пара открывается из своей карточки: из «Обзора», расписания по дням, недельной сетки, календаря «Планов» и подставки «ноутбука». Рамка плавно растягивается до полного экрана (на планшете — до большой панели по центру), картинка карточки растворяется, подробности проявляются. «Назад», предиктивный жест и касание за панелью сворачивают её обратно в карточку. Так же «+» в «Планах» и календаре раскрывается в лист нового задания; лист можно стянуть вниз за ручку.
- **Живая текущая пара.** Вокруг идущей пары по часовой стрелке от левого верхнего угла рисуется рамка: какая часть пары прошла. Она подрастает плавно, без скачков. Цифры обратного отсчёта («ещё 44 мин», «Через 15 мин») прокручиваются, как на табло. Линия «сейчас» в недельной сетке ползёт вниз вместе со временем.
- **Лента дней.** Выбранный день — один круг под всей лентой: при выборе другого дня он перетекает туда на пружинах, передний край спешит, задний догоняет, и по дороге круг вытягивается в каплю. В «Расписании» точка в круге дня значит «есть пары». Когда пролистываешь на другую неделю, её точки появляются по очереди, с понедельника.
- **Выполненное задание.** При отметке текст зачёркивается слева направо, строка за строкой, и задание чуть задерживается на месте, прежде чем уйти к выполненным. Когда закрыто последнее открытое, у «Всё сделано» выскакивает галочка.
- **Обновление жестом.** Потянуть вниз в «Обзоре» и в расписании: экспрессивный индикатор M3 с меняющейся формой и щелчок отклика, когда тянуть уже достаточно.
- **Свежие изменения на виду.** Если университет перезалил документ и пары изменились или добавились, их карточки в расписании дважды вспыхивают цветом (один раз, при первом показе).
- Первый запуск — короткий онбординг: приветствие с тремя возможностями приложения, затем шаги с волнистым индикатором прогресса: группа, подгруппа (только если группа делится на подгруппы) и напоминания. Разрешение на уведомления спрашивается на шаге напоминаний, его можно отложить; «Назад» (с предиктивным жестом) возвращает на предыдущий шаг. После онбординга открывается «Обзор» выбранной группы. - Первый запуск — короткий онбординг: приветствие с тремя возможностями приложения, затем шаги с волнистым индикатором прогресса: группа, подгруппа (только если группа делится на подгруппы) и напоминания. Разрешение на уведомления спрашивается на шаге напоминаний, его можно отложить; «Назад» (с предиктивным жестом) возвращает на предыдущий шаг. После онбординга открывается «Обзор» выбранной группы.
- Обзор: отчёт за неделю (сколько пар прошло), следующие занятия сегодня с обратным отсчётом и ближайший учебный день. - Обзор: отчёт за неделю (сколько пар прошло), следующие занятия сегодня с обратным отсчётом и ближайший учебный день.
- Расписание группы по дням с листанием или недельной сеткой по часам; неделя и её цвет, номера пар, текущая пара, детали занятия с исходной записью Word. Если семестр не найден в таблице недель — строки Word по дням недели. - Расписание группы по дням с листанием или недельной сеткой по часам; неделя и её цвет, номера пар, текущая пара, детали занятия с исходной записью Word. Если семестр не найден в таблице недель — строки Word по дням недели.
- **Занятия на выбор** (немецкий или французский у РГФ). После выбора группы (и подгруппы) приложение спрашивает «Что вы изучаете?», в онбординге это отдельный шаг. Пары другого варианта скрываются из расписания, «Обзора», «Планов», виджетов, напоминаний и уведомлений. Изменить выбор можно в «Группах», в карточке «Сейчас открыта». Пока вариант не выбран, видны обе пары с меткой «на выбор».
- **Иностранные студенты.** В группах, где есть пары только для иностранцев («Русский язык как иностранный») или только для остальных, приложение спрашивает «Вы иностранный студент?» (и в онбординге). Ответ «Да» скрывает пары «кроме иностранных студентов», «Нет» — РКИ и другие пары для иностранцев. Изменить — в «Группах», строка «Иностранный студент». На карточке пары — метка «для иностранных студентов» или «кроме иностранных студентов».
- Предметы: пары по дням текущей недели; у каждого предмета — сокращение как в недельной сетке, следующее занятие с аудиторией, преподаватели и сколько пар на этой неделе. У каждого предмета свой постоянный цвет во всех разделах. - Предметы: пары по дням текущей недели; у каждого предмета — сокращение как в недельной сетке, следующее занятие с аудиторией, преподаватели и сколько пар на этой неделе. У каждого предмета свой постоянный цвет во всех разделах.
- Планы: задания и заметки по дням — к занятию или общие, без привязки к паре, на выбранный день, просроченные сверху, «+» прикрепляет задание к ближайшему занятию. Три макета, как в School Planner: «Повестка дня» (все дни с липкими заголовками и полосой недели, которая следует за списком), «Список» (только дни с делами) и «Доска» (колонки дней). В настройках вида — пары дня и выполненные задания. - Планы: задания и заметки по дням — к занятию или общие, без привязки к паре, на выбранный день, просроченные сверху, «+» прикрепляет задание к ближайшему занятию. Три макета, как в School Planner: «Повестка дня» (все дни с липкими заголовками и полосой недели, которая следует за списком), «Список» (только дни с делами) и «Доска» (колонки дней). В настройках вида — пары дня и выполненные задания.
- Календарь: месяц с отметками дней с парами и с планами, зелёные недели подсвечены; под ним лист с планами и парами выбранного дня. Лист можно поднять — месяц сворачивается до выбранной недели. - Календарь: месяц с отметками дней с парами и с планами, зелёные недели подсвечены; под ним лист с планами и парами выбранного дня. Лист можно поднять — месяц сворачивается до выбранной недели.
@@ -255,9 +285,11 @@ 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»), на карточке пары — значок фото. Просмотр — на весь экран, с листанием, масштабом щипком и двойным касанием. Задание может быть и одним фото, без текста.
- Посещаемость: «был» или «пропустил» в карточке занятия и одним касанием в «Обзоре» для прошедших сегодня пар; статистика по каждому предмету в «Предметах». Хранится на устройстве (`attendance.json`). - Посещаемость: «был» или «пропустил» в карточке занятия и одним касанием в «Обзоре» для прошедших сегодня пар; статистика по каждому предмету в «Предметах». Хранится на устройстве (`attendance.json`).
- Скрытие занятий: «Скрыть такие занятия» в карточке занятия убирает предмет с этим типом занятия из расписания, обзора, виджетов и напоминаний; вернуть можно в настройках. - Скрытие занятий: «Скрыть такие занятия» в карточке занятия убирает предмет с этим типом занятия из расписания, обзора, виджетов и напоминаний; вернуть можно в настройках.
- Второй виджет «Следующая пара» (4×1) и плитка в шторке быстрых настроек с текущей или следующей парой и аудиторией. - Второй виджет «Следующая пара» (4×1) и плитка в шторке быстрых настроек с текущей или следующей парой и аудиторией.
- **Виджет «Неделя»** (4×2 и больше): номер и цвет недели, по плитке на день (Пн–Сб, воскресенье — только если в нём есть пары) с числом пар. Сегодня выделено. Значок города — день в другом городе, значок стрелок — перенос аудитории, внизу строка вроде «Полоцк: вт, пт · перенос: пн, чт». Если растянуть виджет в высоту, в плитках появляются сокращения пар, как в недельной сетке. В воскресенье виджет показывает следующую неделю. Нажатие на день открывает этот день, на заголовок — неделю.
- Настройки отображения: цвета предметов, преподаватели, аудитории, номера пар, метки занятий и пометки из Word можно скрыть; полные сведения остаются в карточке занятия. - Настройки отображения: цвета предметов, преподаватели, аудитории, номера пар, метки занятий и пометки из Word можно скрыть; полные сведения остаются в карточке занятия.
- Группы (кнопка группы в верхней панели «Обзора»; если у группы есть подгруппы, после выбора приложение предложит свою): поиск по номеру во всём каталоге, недавние, каталог по факультету и курсу, выбор подгруппы. - Группы (кнопка группы в верхней панели «Обзора»; если у группы есть подгруппы, после выбора приложение предложит свою): поиск по номеру во всём каталоге, недавние, каталог по факультету и курсу, выбор подгруппы.
- Поиск по университету: занятия преподавателя или аудитории на выбранный день. - Поиск по университету: занятия преподавателя или аудитории на выбранный день.

View File

@@ -59,6 +59,7 @@ dependencies {
implementation(libs.lifecycle.viewmodel.compose) implementation(libs.lifecycle.viewmodel.compose)
implementation(libs.lifecycle.runtime.compose) implementation(libs.lifecycle.runtime.compose)
implementation(libs.core.ktx) implementation(libs.core.ktx)
implementation(libs.splashscreen)
implementation(libs.coroutines.android) implementation(libs.coroutines.android)
implementation(libs.serialization.json) implementation(libs.serialization.json)
implementation(libs.okhttp) implementation(libs.okhttp)

File diff suppressed because it is too large Load Diff

View File

@@ -23,6 +23,7 @@
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:launchMode="singleTop" android:launchMode="singleTop"
android:theme="@style/Theme.PsuSchedule.Starting"
android:windowSoftInputMode="adjustResize"> android:windowSoftInputMode="adjustResize">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
@@ -58,6 +59,18 @@
android:resource="@xml/next_widget_info" /> android:resource="@xml/next_widget_info" />
</receiver> </receiver>
<receiver
android:name=".widget.WeekWidgetReceiver"
android:exported="true"
android:label="@string/week_widget_name">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/week_widget_info" />
</receiver>
<service <service
android:name=".tile.LessonTileService" android:name=".tile.LessonTileService"
android:exported="true" android:exported="true"
@@ -69,6 +82,17 @@
</intent-filter> </intent-filter>
</service> </service>
<!-- The camera app saves a photo for a task here (ui/Photos.kt). -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.photos"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/photo_paths" />
</provider>
<receiver <receiver
android:name=".notify.ReminderReceiver" android:name=".notify.ReminderReceiver"
android:exported="false" /> android:exported="false" />

View File

@@ -33,16 +33,20 @@ sealed interface Launch {
} }
} }
/** The intent of a target: "day" with its [date], "week", "today", "search" or "task". */
fun intent(context: Context, target: String, date: LocalDate? = null): Intent =
Intent(context, MainActivity::class.java)
.setAction(ACTION)
.putExtra(TARGET, target)
.apply { date?.let { putExtra(DATE, it.toString()) } }
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
/** Opens a day of the timetable, for a notification about it. */ /** Opens a day of the timetable, for a notification about it. */
fun day(context: Context, date: LocalDate, request: Int): PendingIntent = fun day(context: Context, date: LocalDate, request: Int): PendingIntent =
PendingIntent.getActivity( PendingIntent.getActivity(
context, context,
request, request,
Intent(context, MainActivity::class.java) intent(context, "day", date),
.setAction(ACTION)
.putExtra(TARGET, "day")
.putExtra(DATE, date.toString())
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
) )
} }

View File

@@ -6,6 +6,11 @@ import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.Orientation 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 org.xtimms.psu.schedule.ui.UndoArea
import org.xtimms.psu.schedule.ui.LocalMorph
import org.xtimms.psu.schedule.ui.MorphHost
import org.xtimms.psu.schedule.ui.MorphOverlay
import 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
@@ -160,6 +165,17 @@ class MainActivity : ComponentActivity() {
} }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
// The launch screen stays until the stored settings are read, so a returning student
// never sees an empty frame; then the icon swells and fades into the app.
val splash = installSplashScreen()
splash.setKeepOnScreenCondition { vm.settings.value == null }
splash.setOnExitAnimationListener { provider ->
val icon = provider.iconView
icon.animate().scaleX(1.15f).scaleY(1.15f).alpha(0f).setDuration(250L)
.setInterpolator(android.view.animation.PathInterpolator(0.3f, 0f, 0.8f, 0.15f)).start()
provider.view.animate().alpha(0f).setStartDelay(100L).setDuration(250L)
.withEndAction { provider.remove() }.start()
}
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
// A recreated activity (rotation) must not open the same screen again. // A recreated activity (rotation) must not open the same screen again.
if (savedInstanceState == null) launch.value = Launch.of(intent) if (savedInstanceState == null) launch.value = Launch.of(intent)
@@ -261,6 +277,10 @@ private fun App(vm: MainViewModel, firstRun: Boolean, launch: Launch?, onLaunche
val slide = rememberSlideDistance() val slide = rememberSlideDistance()
// Behind the pushed-aside screen: the menu's own card-white background. // Behind the pushed-aside screen: the menu's own card-white background.
// "Задание удалено · Вернуть" and the like, over every screen (Undo.kt).
val morph = remember { MorphHost() }
UndoArea(Modifier.fillMaxSize()) {
androidx.compose.runtime.CompositionLocalProvider(LocalMorph provides morph) {
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.
@@ -515,6 +535,10 @@ private fun App(vm: MainViewModel, firstRun: Boolean, launch: Launch?, onLaunche
} }
} }
} }
// A card's details and the "+" sheet open over everything, growing from what was tapped.
MorphOverlay(morph)
}
}
} }
/** What the menu shows and where its items lead. */ /** What the menu shows and where its items lead. */

View File

@@ -1,6 +1,7 @@
package org.xtimms.psu.schedule package org.xtimms.psu.schedule
import android.app.Application import android.app.Application
import kotlinx.coroutines.launch
import org.xtimms.psu.schedule.data.Api import org.xtimms.psu.schedule.data.Api
import org.xtimms.psu.schedule.data.AttendanceStore import org.xtimms.psu.schedule.data.AttendanceStore
import org.xtimms.psu.schedule.data.NotesStore import org.xtimms.psu.schedule.data.NotesStore
@@ -30,6 +31,8 @@ class ScheduleApp : Application() {
org.xtimms.psu.schedule.notify.DayAlerts.createChannel(this) org.xtimms.psu.schedule.notify.DayAlerts.createChannel(this)
org.xtimms.psu.schedule.notify.LiveLesson.createChannel(this) org.xtimms.psu.schedule.notify.LiveLesson.createChannel(this)
SyncWorker.schedule(this) SyncWorker.schedule(this)
// Before any editor opens: photos left by an edit that never got saved.
kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.IO).launch { notes.sweep() }
} }
} }

View File

@@ -12,6 +12,10 @@ data class Timetable(
val semester: Semester? = null, val semester: Semester? = null,
val note: String? = null, val note: String? = null,
val subgroups: List<String> = emptyList(), val subgroups: List<String> = emptyList(),
/** What parts of the group choose between: [["Немецкий язык", "Французский язык"]]. */
val choices: List<List<String>> = emptyList(),
/** Some lessons are for foreign students only ([Lesson.audience]), so the student may say which they are. */
val audience: Boolean = false,
val bells: List<Bell> = emptyList(), val bells: List<Bell> = emptyList(),
val from: String, val from: String,
val days: List<Day> = emptyList(), val days: List<Day> = emptyList(),
@@ -81,6 +85,10 @@ data class Lesson(
val condition: String = "", val condition: String = "",
val variant: String = "all", val variant: String = "all",
val subgroup: String? = null, val subgroup: String? = null,
/** "Немецкий язык": the lesson of the part of the group that took this option (see [Timetable.choices]). */
val option: String? = null,
/** "foreign": for foreign students only (Russian as a foreign language); "local": for the others. */
val audience: String? = null,
val groups: List<String> = emptyList(), val groups: List<String> = emptyList(),
val uncertain: Boolean = false, val uncertain: Boolean = false,
val raw: String = "", val raw: String = "",
@@ -89,6 +97,24 @@ data class Lesson(
/** A recurring lesson the student may hide: the subject with its kind, in every week. */ /** A recurring lesson the student may hide: the subject with its kind, in every week. */
val Lesson.hideKey: String get() = subject.trim().lowercase() + "|" + type.trim().lowercase() val Lesson.hideKey: String get() = subject.trim().lowercase() + "|" + type.trim().lowercase()
/** How a choice's option the student did not take sits among the hidden lessons. */
const val OPTION_PREFIX = "option|"
fun optionKey(option: String): String = OPTION_PREFIX + option.trim().lowercase()
/** How the lessons for foreign students, or for the others, sit among the hidden ones. */
const val AUDIENCE_PREFIX = "audience|"
/**
* Hidden by the student, the lesson of another option than theirs (German when they study
* French), or one for foreign students when they are not (and the other way round).
*/
fun Lesson.hiddenIn(hidden: Set<String>): Boolean =
hideKey in hidden || (option != null && optionKey(option) in hidden) || (audience != null && AUDIENCE_PREFIX + audience in hidden)
/** One choice of a group, the same whatever order the document lists its options in. */
fun choiceKey(options: List<String>): String = options.map { it.trim().lowercase() }.sorted().joinToString("|")
/** A room label with the lesson's town when the document names one: "ауд. 203Г · Полоцк", or the town alone. */ /** A room label with the lesson's town when the document names one: "ауд. 203Г · Полоцк", or the town alone. */
fun withPlace(room: String?, place: String?): String? = fun withPlace(room: String?, place: String?): String? =
listOfNotNull(room?.takeIf { it.isNotEmpty() }, place).joinToString(" · ").ifEmpty { null } listOfNotNull(room?.takeIf { it.isNotEmpty() }, place).joinToString(" · ").ifEmpty { null }

View File

@@ -1,6 +1,11 @@
package org.xtimms.psu.schedule.data package org.xtimms.psu.schedule.data
import android.content.Context import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Matrix
import android.media.ExifInterface
import android.net.Uri
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -27,13 +32,16 @@ data class Note(
val task: Boolean = true, val task: Boolean = true,
val done: Boolean = false, val done: Boolean = false,
val created: Long = System.currentTimeMillis(), val created: Long = System.currentTimeMillis(),
/** Photos of the board or the assignment: file names in [NotesStore.photos]. */
val photos: List<String> = emptyList(),
) )
fun groupKey(g: SavedGroup) = "${g.docId}|${g.group}" fun groupKey(g: SavedGroup) = "${g.docId}|${g.group}"
/** Notes live only on the device, in one small JSON file. */ /** Notes live only on the device, in one small JSON file; their photos in a folder beside it. */
class NotesStore(context: Context) { class NotesStore(private val context: Context) {
private val file = File(context.filesDir, "notes.json") private val file = File(context.filesDir, "notes.json")
val photos = File(context.filesDir, "photos")
private val lock = Mutex() private val lock = Mutex()
private val _notes = MutableStateFlow(read()) private val _notes = MutableStateFlow(read())
val notes: StateFlow<List<Note>> = _notes val notes: StateFlow<List<Note>> = _notes
@@ -50,10 +58,77 @@ class NotesStore(context: Context) {
} }
suspend fun save(note: Note) = change { list -> suspend fun save(note: Note) = change { list ->
// Photos taken out of the note go from the disk too.
list.firstOrNull { it.id == note.id }?.let { discard(it.photos - note.photos.toSet()) }
if (list.any { it.id == note.id }) list.map { if (it.id == note.id) note else it } else list + note if (list.any { it.id == note.id }) list.map { if (it.id == note.id) note else it } else list + note
} }
suspend fun delete(id: String) = change { list -> list.filterNot { it.id == id } } suspend fun delete(id: String) = change { list ->
list.firstOrNull { it.id == id }?.let { discard(it.photos) }
list.filterNot { it.id == id }
}
/** Takes a note out of the list but keeps its photos, so "Вернуть" can bring it back whole. */
suspend fun remove(id: String): Note? {
var removed: Note? = null
change { list ->
removed = list.firstOrNull { it.id == id }
list.filterNot { it.id == id }
}
return removed
}
fun photo(name: String) = File(photos, name)
/**
* A picked or taken picture, kept as a JPEG of at most [MAX_SIDE] px on its long side and
* turned upright: a phone photo of a board is a few hundred kilobytes, not several megabytes.
* Returns the file name, or null when the picture cannot be read.
*/
suspend fun importPhoto(uri: Uri): String? = withContext(Dispatchers.IO) {
runCatching {
val resolver = context.contentResolver
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
resolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, bounds) }
var sample = 1
while (maxOf(bounds.outWidth, bounds.outHeight) / (sample * 2) >= MAX_SIDE) sample *= 2
val decoded = resolver.openInputStream(uri)?.use {
BitmapFactory.decodeStream(it, null, BitmapFactory.Options().apply { inSampleSize = sample })
} ?: return@runCatching null
val rotation = resolver.openInputStream(uri)?.use {
when (ExifInterface(it).getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)) {
ExifInterface.ORIENTATION_ROTATE_90 -> 90f
ExifInterface.ORIENTATION_ROTATE_180 -> 180f
ExifInterface.ORIENTATION_ROTATE_270 -> 270f
else -> 0f
}
} ?: 0f
val scale = minOf(1f, MAX_SIDE.toFloat() / maxOf(decoded.width, decoded.height))
val matrix = Matrix().apply { postScale(scale, scale); postRotate(rotation) }
val upright = Bitmap.createBitmap(decoded, 0, 0, decoded.width, decoded.height, matrix, true)
photos.mkdirs()
val name = UUID.randomUUID().toString() + ".jpg"
photo(name).outputStream().use { upright.compress(Bitmap.CompressFormat.JPEG, 85, it) }
name
}.getOrNull()
}
/** Removes photo files: of a deleted note, or added to a note that was never saved. */
fun discard(names: Collection<String>) {
names.forEach { photo(it).delete() }
}
/** Photos no note refers to any more, after a crash mid-edit, for example. */
suspend fun sweep() = withContext(Dispatchers.IO) {
lock.withLock {
val used = _notes.value.flatMap { it.photos }.toSet()
photos.listFiles()?.filter { it.name !in used }?.forEach { it.delete() }
}
}
private companion object {
const val MAX_SIDE = 2048
}
suspend fun toggle(id: String) = change { list -> list.map { if (it.id == id) it.copy(done = !it.done) else it } } suspend fun toggle(id: String) = change { list -> list.map { if (it.id == id) it.copy(done = !it.done) else it } }
} }

View File

@@ -27,6 +27,13 @@ class Repository(context: Context, val api: Api, private val settings: Settings)
private val _changes = MutableStateFlow(readChanges()) private val _changes = MutableStateFlow(readChanges())
val changes: StateFlow<List<ScheduleChanges>> = _changes val changes: StateFlow<List<ScheduleChanges>> = _changes
// Lessons just added or changed by a republished document: their cards glow once when shown.
private val _fresh = MutableStateFlow<Set<String>>(emptySet())
val fresh: StateFlow<Set<String>> = _fresh
fun freshShown(key: String) {
_fresh.value = _fresh.value - key
}
private fun readChanges(): List<ScheduleChanges> = private fun readChanges(): List<ScheduleChanges> =
runCatching { json.decodeFromString<List<ScheduleChanges>>(changesCache.readText()) }.getOrDefault(emptyList()) runCatching { json.decodeFromString<List<ScheduleChanges>>(changesCache.readText()) }.getOrDefault(emptyList())
@@ -58,6 +65,7 @@ class Repository(context: Context, val api: Api, private val settings: Settings)
changed = changed, changed = changed,
) )
writeChanges((listOf(record) + _changes.value.filter { it.document != record.document || it.group != record.group }).take(12)) writeChanges((listOf(record) + _changes.value.filter { it.document != record.document || it.group != record.group }).take(12))
_fresh.value = (added.map { it.lesson.key } + changed.map { it.after.lesson.key }).toSet()
} }
/** The overview's card was opened or closed: the record stays for "Последние изменения". */ /** The overview's card was opened or closed: the record stays for "Последние изменения". */
@@ -110,7 +118,7 @@ class Repository(context: Context, val api: Api, private val settings: Settings)
fun lessonsOn(timetable: Timetable, date: LocalDate, subgroup: String?, hidden: Set<String> = emptySet()): List<Lesson> = fun lessonsOn(timetable: Timetable, date: LocalDate, subgroup: String?, hidden: Set<String> = emptySet()): List<Lesson> =
timetable.days.firstOrNull { it.date == date.toString() }?.lessons.orEmpty() timetable.days.firstOrNull { it.date == date.toString() }?.lessons.orEmpty()
.filter { subgroup == null || it.subgroup == null || it.subgroup == subgroup } .filter { subgroup == null || it.subgroup == null || it.subgroup == subgroup }
.filter { it.hideKey !in hidden } .filter { !it.hiddenIn(hidden) }
fun dayOf(timetable: Timetable, date: LocalDate): Day? = fun dayOf(timetable: Timetable, date: LocalDate): Day? =
timetable.days.firstOrNull { it.date == date.toString() } timetable.days.firstOrNull { it.date == date.toString() }

View File

@@ -58,8 +58,15 @@ data class UserSettings(
val liveLesson: Boolean = false, val liveLesson: Boolean = false,
val recent: List<SavedGroup> = emptyList(), val recent: List<SavedGroup> = emptyList(),
val display: Display = Display(), val display: Display = Display(),
/** Lessons hidden in the open group, as [Lesson.hideKey]s. */ /**
* Lessons hidden in the open group, as [Lesson.hideKey]s, and the options of its choices the
* student did not take, as [optionKey]s.
*/
val hidden: Set<String> = emptySet(), val hidden: Set<String> = emptySet(),
/** The open group's choices ([choiceKey]) and the option taken in each: "Французский язык". */
val choices: Map<String, String> = emptyMap(),
/** Whether the student is a foreign student in the open group; null: show every lesson. */
val foreign: Boolean? = null,
val plans: PlansPrefs = PlansPrefs(), val plans: PlansPrefs = PlansPrefs(),
) )
@@ -70,6 +77,8 @@ class Settings(private val context: Context) {
private object Keys { private object Keys {
val current = stringPreferencesKey("current") val current = stringPreferencesKey("current")
val subgroups = stringPreferencesKey("subgroups") val subgroups = stringPreferencesKey("subgroups")
val choices = stringPreferencesKey("choices")
val foreign = stringPreferencesKey("foreign")
val theme = stringPreferencesKey("theme") val theme = stringPreferencesKey("theme")
val dynamic = booleanPreferencesKey("dynamic_color") val dynamic = booleanPreferencesKey("dynamic_color")
val reminders = booleanPreferencesKey("reminders") val reminders = booleanPreferencesKey("reminders")
@@ -94,6 +103,12 @@ class Settings(private val context: Context) {
val plansDone = booleanPreferencesKey("plans_done") val plansDone = booleanPreferencesKey("plans_done")
} }
private fun Preferences.choiceMap(): Map<String, Map<String, String>> =
this[Keys.choices]?.let { runCatching { json.decodeFromString<Map<String, Map<String, String>>>(it) }.getOrNull() }.orEmpty()
private fun Preferences.foreignMap(): Map<String, Boolean> =
this[Keys.foreign]?.let { runCatching { json.decodeFromString<Map<String, Boolean>>(it) }.getOrNull() }.orEmpty()
private fun Preferences.hiddenMap(): Map<String, List<String>> = private fun Preferences.hiddenMap(): Map<String, List<String>> =
this[Keys.hidden]?.let { runCatching { json.decodeFromString<Map<String, List<String>>>(it) }.getOrNull() }.orEmpty() this[Keys.hidden]?.let { runCatching { json.decodeFromString<Map<String, List<String>>>(it) }.getOrNull() }.orEmpty()
@@ -106,6 +121,14 @@ class Settings(private val context: Context) {
val subgroups = this[Keys.subgroups]?.let { val subgroups = this[Keys.subgroups]?.let {
runCatching { json.decodeFromString<Map<String, String>>(it) }.getOrNull() runCatching { json.decodeFromString<Map<String, String>>(it) }.getOrNull()
}.orEmpty() }.orEmpty()
val choices = current?.let { choiceMap()[subgroupKey(it)] }.orEmpty()
val foreign = current?.let { foreignMap()[subgroupKey(it)] }
// A foreign student does not see the others' lessons, and the other way round.
val audience = when (foreign) {
true -> setOf(AUDIENCE_PREFIX + "local")
false -> setOf(AUDIENCE_PREFIX + "foreign")
null -> emptySet()
}
return UserSettings( return UserSettings(
current = current, current = current,
subgroup = current?.let { subgroups[subgroupKey(it)] }, subgroup = current?.let { subgroups[subgroupKey(it)] },
@@ -123,7 +146,9 @@ class Settings(private val context: Context) {
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(),
hidden = current?.let { hiddenMap()[subgroupKey(it)] }.orEmpty().toSet(), hidden = current?.let { hiddenMap()[subgroupKey(it)] }.orEmpty().toSet() + skipped(choices) + audience,
choices = choices,
foreign = foreign,
plans = PlansPrefs( plans = PlansPrefs(
layout = this[Keys.plansLayout]?.let { runCatching { PlansLayout.valueOf(it) }.getOrNull() } ?: PlansLayout.Agenda, layout = this[Keys.plansLayout]?.let { runCatching { PlansLayout.valueOf(it) }.getOrNull() } ?: PlansLayout.Agenda,
lessons = this[Keys.plansLessons] ?: false, lessons = this[Keys.plansLessons] ?: false,
@@ -142,6 +167,10 @@ class Settings(private val context: Context) {
private fun subgroupKey(g: SavedGroup) = "${g.docId}|${g.group}" private fun subgroupKey(g: SavedGroup) = "${g.docId}|${g.group}"
/** The options not taken: their lessons are hidden like the ones the student hid. */
private fun skipped(choices: Map<String, String>): Set<String> =
choices.flatMap { (key, taken) -> key.split("|").filter { it != taken.trim().lowercase() }.map(::optionKey) }.toSet()
suspend fun openGroup(group: SavedGroup) = context.store.edit { prefs -> suspend fun openGroup(group: SavedGroup) = context.store.edit { prefs ->
prefs[Keys.current] = json.encodeToString(group) prefs[Keys.current] = json.encodeToString(group)
val recent = prefs[Keys.recent]?.let { val recent = prefs[Keys.recent]?.let {
@@ -169,6 +198,26 @@ class Settings(private val context: Context) {
prefs[Keys.subgroups] = json.encodeToString(map.toMap()) prefs[Keys.subgroups] = json.encodeToString(map.toMap())
} }
/** The option the student takes in a choice of the open group ([option] null: all of them). */
suspend fun setChoice(options: List<String>, option: String?) = context.store.edit { prefs ->
val current = prefs[Keys.current]?.let { runCatching { json.decodeFromString<SavedGroup>(it) }.getOrNull() }
?: return@edit
val all = prefs.choiceMap().toMutableMap()
val mine = all[subgroupKey(current)].orEmpty().toMutableMap()
if (option == null) mine.remove(choiceKey(options)) else mine[choiceKey(options)] = option
all[subgroupKey(current)] = mine
prefs[Keys.choices] = json.encodeToString(all.toMap())
}
/** Whether the student is a foreign student in the open group; null forgets the answer. */
suspend fun setForeign(foreign: Boolean?) = context.store.edit { prefs ->
val current = prefs[Keys.current]?.let { runCatching { json.decodeFromString<SavedGroup>(it) }.getOrNull() }
?: return@edit
val map = prefs.foreignMap().toMutableMap()
if (foreign == null) map.remove(subgroupKey(current)) else map[subgroupKey(current)] = foreign
prefs[Keys.foreign] = json.encodeToString(map.toMap())
}
suspend fun setTheme(mode: ThemeMode) = context.store.edit { it[Keys.theme] = mode.name } suspend fun setTheme(mode: ThemeMode) = context.store.edit { it[Keys.theme] = mode.name }
suspend fun setDynamicColor(on: Boolean) = context.store.edit { it[Keys.dynamic] = on } suspend fun setDynamicColor(on: Boolean) = context.store.edit { it[Keys.dynamic] = on }
suspend fun setReminders(on: Boolean) = context.store.edit { it[Keys.reminders] = on } suspend fun setReminders(on: Boolean) = context.store.edit { it[Keys.reminders] = on }

View File

@@ -1,6 +1,6 @@
package org.xtimms.psu.schedule.notify package org.xtimms.psu.schedule.notify
import org.xtimms.psu.schedule.data.hideKey import org.xtimms.psu.schedule.data.hiddenIn
import org.xtimms.psu.schedule.data.withPlace import org.xtimms.psu.schedule.data.withPlace
import org.xtimms.psu.schedule.data.forSubgroup import org.xtimms.psu.schedule.data.forSubgroup
import android.Manifest import android.Manifest
@@ -79,7 +79,7 @@ object Reminders {
val date = parseDate(day.date) val date = parseDate(day.date)
val shown = day.lessons val shown = day.lessons
.filter { settings.subgroup == null || it.subgroup == null || it.subgroup == settings.subgroup } .filter { settings.subgroup == null || it.subgroup == null || it.subgroup == settings.subgroup }
.filter { it.hideKey !in settings.hidden } .filter { !it.hiddenIn(settings.hidden) }
// A day in another town: the first lesson is announced earlier, for the road. // A day in another town: the first lesson is announced earlier, for the road.
val away = settings.travelAlerts && day.places.forSubgroup(settings.subgroup).isNotEmpty() val away = settings.travelAlerts && day.places.forSubgroup(settings.subgroup).isNotEmpty()
val first = shown.minOfOrNull { it.start } val first = shown.minOfOrNull { it.start }

View File

@@ -2,6 +2,8 @@
package org.xtimms.psu.schedule.ui package org.xtimms.psu.schedule.ui
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -83,6 +85,7 @@ fun LessonAttendance(vm: MainViewModel, lesson: Lesson, date: LocalDate) {
@Composable @Composable
fun AttendanceCard(vm: MainViewModel, slots: List<Slot>) { fun AttendanceCard(vm: MainViewModel, slots: List<Slot>) {
val marks by vm.marks.collectAsStateWithLifecycle() val marks by vm.marks.collectAsStateWithLifecycle()
val haptic = LocalHapticFeedback.current
if (vm.currentGroupKey() == null) return if (vm.currentGroupKey() == null) return
val now = nowTime() val now = nowTime()
val date = today() val date = today()
@@ -103,11 +106,17 @@ fun AttendanceCard(vm: MainViewModel, slots: List<Slot>) {
Text(lesson.subject, style = MaterialTheme.typography.bodyLargeEmphasized, maxLines = 2, overflow = TextOverflow.Ellipsis) Text(lesson.subject, style = MaterialTheme.typography.bodyLargeEmphasized, maxLines = 2, overflow = TextOverflow.Ellipsis)
TimeText("${slot.start} → ${slot.end}", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) TimeText("${slot.start} → ${slot.end}", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
} }
FilledTonalIconButton(onClick = { vm.mark(lesson, date, true) }) { FilledTonalIconButton(onClick = {
haptic.performHapticFeedback(HapticFeedbackType.Confirm)
vm.mark(lesson, date, true)
}) {
Icon(Icons.Rounded.Check, contentDescription = "Был на паре «${lesson.subject}»") Icon(Icons.Rounded.Check, contentDescription = "Был на паре «${lesson.subject}»")
} }
FilledTonalIconButton( FilledTonalIconButton(
onClick = { vm.mark(lesson, date, false) }, onClick = {
haptic.performHapticFeedback(HapticFeedbackType.Confirm)
vm.mark(lesson, date, false)
},
colors = IconButtonDefaults.filledTonalIconButtonColors( colors = IconButtonDefaults.filledTonalIconButtonColors(
containerColor = MaterialTheme.colorScheme.errorContainer, containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer, contentColor = MaterialTheme.colorScheme.onErrorContainer,

View File

@@ -123,13 +123,14 @@ private fun weekRow(month: YearMonth, d: LocalDate) = mondays(month).indexOf(d.m
*/ */
@Composable @Composable
fun CalendarScreen(vm: MainViewModel, nav: AppNav) { fun CalendarScreen(vm: MainViewModel, nav: AppNav) {
val undo = LocalUndo.current
val timetable by vm.timetable.collectAsStateWithLifecycle() val timetable by vm.timetable.collectAsStateWithLifecycle()
val settings by vm.settings.collectAsStateWithLifecycle() val settings by vm.settings.collectAsStateWithLifecycle()
val notes by vm.notes.collectAsStateWithLifecycle() val notes by vm.notes.collectAsStateWithLifecycle()
var selected by remember { mutableStateOf(today()) } var selected by remember { mutableStateOf(today()) }
var details by remember { mutableStateOf<Pair<Lesson, LocalDate>?>(null) } val openLesson = rememberLessonOpener(vm)
var editing by remember { mutableStateOf<Note?>(null) } var editing by remember { mutableStateOf<Note?>(null) }
var adding by remember { mutableStateOf(false) } val morph = LocalMorph.current
var target by remember { mutableStateOf<NoteTarget?>(null) } var target by remember { mutableStateOf<NoteTarget?>(null) }
val base = remember { YearMonth.from(today()).minusMonths(MONTHS_BEFORE.toLong()) } val base = remember { YearMonth.from(today()).minusMonths(MONTHS_BEFORE.toLong()) }
val pager = rememberPagerState(initialPage = MONTHS_BEFORE) { MONTHS } val pager = rememberPagerState(initialPage = MONTHS_BEFORE) { MONTHS }
@@ -254,7 +255,7 @@ fun CalendarScreen(vm: MainViewModel, nav: AppNav) {
val agenda: @Composable (Modifier) -> Unit = { modifier -> val agenda: @Composable (Modifier) -> Unit = { modifier ->
DayAgenda( DayAgenda(
vm, t, subgroup, selected, notes, vm, t, subgroup, selected, notes,
onLesson = { details = it to selected }, onLesson = { openLesson(it, selected) },
onNote = { editing = it }, onNote = { editing = it },
modifier = modifier, modifier = modifier,
) )
@@ -304,15 +305,23 @@ fun CalendarScreen(vm: MainViewModel, nav: AppNav) {
} }
} }
} }
// "+" grows into the sheet of a new plan for the chosen day (Morph.kt).
FloatingActionButton( FloatingActionButton(
onClick = { adding = true }, onClick = {
modifier = Modifier.align(Alignment.BottomEnd).padding(end = 16.dp, bottom = 16.dp + navBarPadding()), morph?.open(MorphKind.Sheet) { close ->
AddPlanSheet(vm, t, subgroup, day = selected) {
close()
target = it
}
}
},
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(end = 16.dp, bottom = 16.dp + navBarPadding())
.morphOrigin("fab|calendar", MaterialTheme.colorScheme.primaryContainer, 16.dp),
) { Icon(Icons.Rounded.Add, contentDescription = "Новый план") } ) { Icon(Icons.Rounded.Add, contentDescription = "Новый план") }
} }
if (adding) ModalBottomSheet(onDismissRequest = { adding = false }) {
AddPlanSheet(vm, t, subgroup, day = selected) { adding = false; target = it }
}
target?.let { tg -> target?.let { tg ->
vm.currentGroupKey()?.let { group -> vm.currentGroupKey()?.let { group ->
NoteEditor( NoteEditor(
@@ -325,18 +334,13 @@ fun CalendarScreen(vm: MainViewModel, nav: AppNav) {
) )
} }
} }
details?.let { (lesson, date) ->
ModalBottomSheet(onDismissRequest = { details = null }) {
LessonDetails(lesson, t?.bells.orEmpty(), date, vm, onClose = { details = null })
}
}
editing?.let { note -> editing?.let { note ->
NoteEditor( NoteEditor(
target = null, target = null,
editing = note, editing = note,
groupKey = note.group, groupKey = note.group,
onSave = { vm.saveNote(it); editing = null }, onSave = { vm.saveNote(it); editing = null },
onDelete = { vm.deleteNote(it.id); editing = null }, onDelete = { vm.deleteNote(it.id, undo); editing = null },
onDismiss = { editing = null }, onDismiss = { editing = null },
) )
} }
@@ -504,11 +508,22 @@ private fun DayAgendaContent(
item(key = "plans") { AnimatedItem { Section(number, "Ваши планы", trailing = date.format(DATE)) } } item(key = "plans") { AnimatedItem { Section(number, "Ваши планы", trailing = date.format(DATE)) } }
plans.forEachIndexed { i, note -> plans.forEachIndexed { i, note ->
item(key = note.id) { AnimatedItem { item(key = note.id) { AnimatedItem {
// Struck through in place first, then the list moves it down.
var shownDone by remember(note.id, note.done) { mutableStateOf(note.done) }
val scope = rememberCoroutineScope()
TimelineItem( TimelineItem(
marker = { PlanMarker(note, onToggle = { vm.toggleNote(note.id) }) }, marker = {
PlanMarker(note.copy(done = shownDone), onToggle = {
shownDone = !shownDone
if (shownDone) scope.launch {
kotlinx.coroutines.delay(DONE_PAUSE)
vm.toggleNote(note.id)
} else vm.toggleNote(note.id)
})
},
over = note.subject.ifBlank { null }, over = note.subject.ifBlank { null },
title = note.text, title = note.text,
done = note.task && note.done, done = note.task && shownDone,
last = i == plans.lastIndex, last = i == plans.lastIndex,
onClick = { onNote(note) }, onClick = { onNote(note) },
) )
@@ -540,6 +555,7 @@ private fun DayAgendaContent(
).joinToString(" · ").ifEmpty { null }, ).joinToString(" · ").ifEmpty { null },
last = i == lessons.lastIndex, last = i == lessons.lastIndex,
onClick = { onLesson(l) }, onClick = { onLesson(l) },
origin = Modifier.morphOrigin("cal|$date|${l.key}|${l.start}", MaterialTheme.colorScheme.surfaceContainerLow, 16.dp),
) )
} }
} }
@@ -593,6 +609,8 @@ private fun TimelineItem(
overColor: Color? = null, overColor: Color? = null,
last: Boolean, last: Boolean,
onClick: () -> Unit, onClick: () -> Unit,
/** For a lesson: where its details page grows from. */
origin: Modifier = Modifier,
) { ) {
val line = MaterialTheme.colorScheme.outlineVariant val line = MaterialTheme.colorScheme.outlineVariant
// Level with the first line of the title: 14dp padding, a 20dp line above it, a 24dp title line. // Level with the first line of the title: 14dp padding, a 20dp line above it, a 24dp title line.
@@ -601,6 +619,7 @@ private fun TimelineItem(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.height(IntrinsicSize.Min) .height(IntrinsicSize.Min)
.then(origin)
.clip(MaterialTheme.shapes.large) .clip(MaterialTheme.shapes.large)
.clickable(onClickLabel = "Подробнее", role = Role.Button, onClick = onClick), .clickable(onClickLabel = "Подробнее", role = Role.Button, onClick = onClick),
) { ) {
@@ -626,14 +645,7 @@ private fun TimelineItem(
over?.let { over?.let {
TimeText(it, style = MaterialTheme.typography.bodyMedium, color = overColor ?: MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1) TimeText(it, style = MaterialTheme.typography.bodyMedium, color = overColor ?: MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
} }
Text( StrikeText(title, done = done, style = MaterialTheme.typography.titleMedium, maxLines = 2)
title,
style = MaterialTheme.typography.titleMedium,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
textDecoration = if (done) TextDecoration.LineThrough else null,
color = if (done) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface,
)
under?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1) } under?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1) }
} }
} }

View File

@@ -2,6 +2,8 @@
package org.xtimms.psu.schedule.ui package org.xtimms.psu.schedule.ui
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
@@ -21,6 +23,26 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import java.time.temporal.ChronoUnit
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.ui.graphics.lerp import androidx.compose.ui.graphics.lerp
@@ -164,6 +186,7 @@ fun <T> ConnectedChoice(
/** The check before the chosen label; times like "20:00" need the room, the fill marks it. */ /** The check before the chosen label; times like "20:00" need the room, the fill marks it. */
mark: Boolean = true, mark: Boolean = true,
) { ) {
val haptic = LocalHapticFeedback.current
Row( Row(
modifier.selectableGroup(), modifier.selectableGroup(),
horizontalArrangement = Arrangement.spacedBy(ButtonGroupDefaults.ConnectedSpaceBetween), horizontalArrangement = Arrangement.spacedBy(ButtonGroupDefaults.ConnectedSpaceBetween),
@@ -172,7 +195,10 @@ fun <T> ConnectedChoice(
val checked = value == selected val checked = value == selected
ToggleButton( ToggleButton(
checked = checked, checked = checked,
onCheckedChange = { onSelect(value) }, onCheckedChange = {
if (!checked) haptic.performHapticFeedback(HapticFeedbackType.SegmentTick)
onSelect(value)
},
modifier = Modifier.weight(1f).semantics { role = Role.RadioButton }, modifier = Modifier.weight(1f).semantics { role = Role.RadioButton },
// Unselected segments need their own tonal fill to read as one group. // Unselected segments need their own tonal fill to read as one group.
colors = ToggleButtonDefaults.colors( colors = ToggleButtonDefaults.colors(
@@ -357,6 +383,10 @@ fun weekTintOf(week: Week?): Color {
* Seven days of a week in School Planner's calm style: the weekday, the date in a circle, * Seven days of a week in School Planner's calm style: the weekday, the date in a circle,
* a filled circle for the selected day and primary text for today. Swiping between weeks * a filled circle for the selected day and primary text for today. Swiping between weeks
* is the caller's pager; TalkBack gets the same moves as custom actions. * is the caller's pager; TalkBack gets the same moves as custom actions.
*
* The filled circle is one shape under the whole row: picking another day, it flows there on
* springs, its leading edge ahead of the trailing one. With [marks], a dot in a day's circle
* says it has lessons; the dots of a week pop in one day after another when it is picked.
*/ */
@Composable @Composable
fun DayStrip( fun DayStrip(
@@ -366,8 +396,38 @@ fun DayStrip(
enabled: (LocalDate) -> Boolean = { true }, enabled: (LocalDate) -> Boolean = { true },
onSelect: (LocalDate) -> Unit, onSelect: (LocalDate) -> Unit,
onWeek: ((Int) -> Unit)? = null, onWeek: ((Int) -> Unit)? = null,
marks: Boolean = false,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val haptic = LocalHapticFeedback.current
val scheme = MaterialTheme.colorScheme
val reduced = rememberReducedMotion()
val index = selected?.let { ChronoUnit.DAYS.between(monday, it).toInt() }?.takeIf { it in 0..6 }
// The indicator's edges, in days from Monday.
val left = remember { Animatable(index?.toFloat() ?: 0f) }
val right = remember { Animatable(index?.toFloat() ?: 0f) }
// False until the indicator stands in this week: a week just picked gets it in place, not sliding in.
var placed by remember { mutableStateOf(index != null) }
LaunchedEffect(index) {
if (index == null) {
placed = false
return@LaunchedEffect
}
val target = index.toFloat()
if (!placed || reduced) {
left.snapTo(target)
right.snapTo(target)
placed = true
return@LaunchedEffect
}
val forward = target > left.value
val lead = spring<Float>(dampingRatio = 0.8f, stiffness = Spring.StiffnessMedium)
val trail = spring<Float>(dampingRatio = 0.8f, stiffness = Spring.StiffnessMediumLow)
launch { (if (forward) right else left).animateTo(target, lead) }
(if (forward) left else right).animateTo(target, trail)
}
var rowTop by remember { mutableFloatStateOf(0f) }
var circleTop by remember { mutableFloatStateOf(-1f) }
Row( Row(
modifier modifier
.fillMaxWidth() .fillMaxWidth()
@@ -380,14 +440,48 @@ fun DayStrip(
CustomAccessibilityAction("Следующая неделя") { onWeek(1); true }, CustomAccessibilityAction("Следующая неделя") { onWeek(1); true },
) )
}, },
), )
.onGloballyPositioned { rowTop = it.positionInRoot().y }
.drawBehind {
if (index == null || circleTop < 0f) return@drawBehind
val cell = size.width / 7
val d = 40.dp.toPx()
val from = if (placed) left.value else index.toFloat()
val to = if (placed) right.value else index.toFloat()
drawRoundRect(
scheme.primary,
topLeft = Offset(from * cell + (cell - d) / 2, circleTop - rowTop),
size = Size((to - from) * cell + d, d),
cornerRadius = CornerRadius(d / 2),
)
},
) { ) {
repeat(7) { i -> repeat(7) { i ->
val date = monday.plusDays(i.toLong()) val date = monday.plusDays(i.toLong())
val isSelected = date == selected val isSelected = date == selected
val isToday = date == today() val isToday = date == today()
val busy = hasLessons(date) val busy = hasLessons(date)
val scheme = MaterialTheme.colorScheme val ink by animateColorAsState(
when {
isSelected -> scheme.onPrimary
isToday -> scheme.primary
busy -> scheme.onSurface
else -> scheme.onSurfaceVariant
},
if (reduced) snap() else tween(200),
label = "day-ink",
)
// A week's dots wait until it is the one picked, then pop in from Monday on.
val dot = remember(monday) { Animatable(0f) }
val dotShown = marks && busy && (selected == null || index != null)
LaunchedEffect(monday, dotShown) {
if (!dotShown) dot.snapTo(0f)
else if (reduced || dot.value > 0f) dot.snapTo(1f)
else {
delay(60L + 35L * i)
dot.animateTo(1f, spring(dampingRatio = 0.5f, stiffness = Spring.StiffnessMedium))
}
}
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier modifier = Modifier
@@ -399,7 +493,10 @@ fun DayStrip(
role = Role.Tab, role = Role.Tab,
interactionSource = null, interactionSource = null,
indication = null, indication = null,
onClick = { onSelect(date) }, onClick = {
if (!isSelected) haptic.performHapticFeedback(HapticFeedbackType.SegmentTick)
onSelect(date)
},
) )
.padding(vertical = 6.dp) .padding(vertical = 6.dp)
.semantics(mergeDescendants = true) { .semantics(mergeDescendants = true) {
@@ -417,17 +514,16 @@ fun DayStrip(
modifier = Modifier modifier = Modifier
.padding(top = 4.dp) .padding(top = 4.dp)
.size(40.dp) .size(40.dp)
.background(if (isSelected) scheme.primary else Color.Transparent, CircleShape), .then(if (i == 0) Modifier.onGloballyPositioned { circleTop = it.positionInRoot().y } else Modifier),
) { ) {
Text( Text("${date.dayOfMonth}", style = MaterialTheme.typography.titleMediumEmphasized, color = ink)
"${date.dayOfMonth}", if (marks) Box(
style = MaterialTheme.typography.titleMediumEmphasized, Modifier
color = when { .align(Alignment.BottomCenter)
isSelected -> scheme.onPrimary .padding(bottom = 4.dp)
isToday -> scheme.primary .size(4.dp)
busy -> scheme.onSurface .graphicsLayer { scaleX = dot.value; scaleY = dot.value }
else -> scheme.onSurfaceVariant .background(ink, CircleShape),
},
) )
} }
} }

View File

@@ -0,0 +1,119 @@
package org.xtimms.psu.schedule.ui
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
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 androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathMeasure
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
// 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 "Всё сделано".
/** A task's text, struck through as it is done; the line draws itself instead of appearing. */
@Composable
fun StrikeText(text: String, done: Boolean, style: TextStyle, maxLines: Int, modifier: Modifier = Modifier) {
val reduced = rememberReducedMotion()
val strike by animateFloatAsState(if (done) 1f else 0f, if (reduced) snap() else tween(350, easing = EmphasizedDecelerate), label = "strike")
val color by animateColorAsState(
if (done) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface,
tween(350),
label = "done-color",
)
var layout by remember { mutableStateOf<TextLayoutResult?>(null) }
Text(
text,
style = style,
color = color,
maxLines = maxLines,
overflow = TextOverflow.Ellipsis,
onTextLayout = { layout = it },
modifier = modifier.drawWithContent {
drawContent()
val l = layout ?: return@drawWithContent
if (strike <= 0f) return@drawWithContent
// One run across all the lines: the first line is struck through, then the next.
val widths = (0 until l.lineCount).map { l.getLineRight(it) - l.getLineLeft(it) }
var left = widths.sum() * strike
for (i in 0 until l.lineCount) {
if (left <= 0f) break
val w = minOf(widths[i], left)
val y = (l.getLineTop(i) + l.getLineBottom(i)) / 2
drawLine(color, Offset(l.getLineLeft(i), y), Offset(l.getLineLeft(i) + w, y), strokeWidth = 1.5.dp.toPx())
left -= w
}
},
)
}
/** How long a ticked task stays in place, struck through, before it moves down to the done ones. */
const val DONE_PAUSE = 420L
/**
* A check in a circle that pops up (a springy scale, M3 Expressive) and draws its tick when
* [visible] turns true; already true when shown, it just sits there.
*/
@Composable
fun DoneMark(visible: Boolean, modifier: Modifier = Modifier) {
val reduced = rememberReducedMotion()
val first = remember { visible }
val scale = remember { Animatable(if (first) 1f else 0f) }
val tick = remember { Animatable(if (first) 1f else 0f) }
LaunchedEffect(visible) {
if (!visible) {
scale.snapTo(0f)
tick.snapTo(0f)
return@LaunchedEffect
}
if (reduced) {
scale.snapTo(1f)
tick.snapTo(1f)
return@LaunchedEffect
}
launch { scale.animateTo(1f, spring(dampingRatio = 0.45f, stiffness = Spring.StiffnessMediumLow)) }
delay(120)
tick.animateTo(1f, tween(300, easing = EmphasizedDecelerate))
}
if (!visible && scale.value == 0f) return
val fill = MaterialTheme.colorScheme.primary
val ink = MaterialTheme.colorScheme.onPrimary
Canvas(modifier.padding(end = 6.dp).size(16.dp).graphicsLayer { scaleX = scale.value; scaleY = scale.value }) {
drawCircle(fill)
val path = Path().apply {
moveTo(size.width * 0.28f, size.height * 0.52f)
lineTo(size.width * 0.44f, size.height * 0.68f)
lineTo(size.width * 0.74f, size.height * 0.36f)
}
val measure = PathMeasure().apply { setPath(path, false) }
val drawn = Path()
measure.getSegment(0f, measure.length * tick.value, drawn, true)
drawPath(drawn, ink, style = Stroke(width = 2.dp.toPx(), cap = StrokeCap.Round, join = StrokeJoin.Round))
}
}

View File

@@ -56,6 +56,7 @@ import org.xtimms.psu.schedule.data.GroupResult
import org.xtimms.psu.schedule.data.attempt 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
private val PLAIN_PERIOD = Regex("^(осенний|весенний) семестр$|^расписание из word$", RegexOption.IGNORE_CASE) private val PLAIN_PERIOD = Regex("^(осенний|весенний) семестр$|^расписание из word$", RegexOption.IGNORE_CASE)
@@ -97,15 +98,25 @@ fun GroupsScreen(
} }
val searching = query.trim().length >= 2 val searching = query.trim().length >= 2
// Before going back to the overview, a group with subgroups asks which one is yours, // Before going back to the overview, a group with subgroups asks which one is yours, and a
// unless you already chose it for this group. // group whose parts study German or French which you study, unless you chose it already.
var askSubgroup by remember { mutableStateOf<List<String>?>(null) } var askSubgroup by remember { mutableStateOf<List<String>?>(null) }
var askChoices by remember { mutableStateOf<List<List<String>>>(emptyList()) }
var askForeign by remember { mutableStateOf(false) }
// After the subgroup, the choices and then whether you are a foreign student; then the overview.
fun next() {
if (askSubgroup == null && askChoices.isEmpty() && !askForeign) onOpened()
}
fun opened() { fun opened() {
val t = vm.timetable.value val t = vm.timetable.value
val subgroups = t?.subgroups.orEmpty() val subgroups = t?.subgroups.orEmpty()
val s = vm.settings.value val s = vm.settings.value
val chosen = s?.current?.group == t?.group && s?.subgroup != null val same = s?.current?.group == t?.group
if (subgroups.isNotEmpty() && !chosen && !onboarding) askSubgroup = subgroups else onOpened() if (onboarding) return onOpened()
askChoices = t?.choices.orEmpty().filterNot { same && s?.choices?.containsKey(choiceKey(it)) == true }
askForeign = t?.audience == true && !(same && s?.foreign != null)
if (subgroups.isNotEmpty() && !(same && s?.subgroup != null)) askSubgroup = subgroups
next()
} }
fun open(result: GroupResult) { fun open(result: GroupResult) {
@@ -169,6 +180,12 @@ fun GroupsScreen(
subgroups = timetable?.subgroups.orEmpty(), subgroups = timetable?.subgroups.orEmpty(),
subgroup = settings?.subgroup, subgroup = settings?.subgroup,
onSubgroup = vm::setSubgroup, onSubgroup = vm::setSubgroup,
choices = timetable?.choices.orEmpty(),
taken = settings?.choices.orEmpty(),
onChoice = vm::setChoice,
audience = timetable?.audience == true,
foreign = settings?.foreign,
onForeign = vm::setForeign,
) )
} }
val recent = settings?.recent.orEmpty().filterNot { it.docId == current?.docId && it.group == current?.group } val recent = settings?.recent.orEmpty().filterNot { it.docId == current?.docId && it.group == current?.group }
@@ -233,9 +250,7 @@ fun GroupsScreen(
} }
} }
} }
if (data == null && error == null) item { if (data == null && error == null) item { RowsSkeleton() }
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { LoadingIndicator() }
}
} }
} }
} }
@@ -244,7 +259,32 @@ fun GroupsScreen(
SubgroupDialog( SubgroupDialog(
group = vm.timetable.value?.group.orEmpty(), group = vm.timetable.value?.group.orEmpty(),
subgroups = subgroups, subgroups = subgroups,
onPick = { vm.setSubgroup(it); askSubgroup = null; onOpened() }, onPick = {
vm.setSubgroup(it)
askSubgroup = null
next()
},
)
}
if (askSubgroup == null) askChoices.firstOrNull()?.let { options ->
ChoiceDialog(
group = vm.timetable.value?.group.orEmpty(),
options = options,
onPick = {
vm.setChoice(options, it)
askChoices = askChoices.drop(1)
next()
},
)
}
if (askSubgroup == null && askChoices.isEmpty() && askForeign) {
ForeignDialog(
group = vm.timetable.value?.group.orEmpty(),
onPick = {
vm.setForeign(it)
askForeign = false
next()
},
) )
} }
} }
@@ -270,7 +310,18 @@ private fun GroupRows(results: List<GroupResult>, opening: String?, onOpen: (Gro
} }
@Composable @Composable
private fun CurrentGroup(current: SavedGroup, subgroups: List<String>, subgroup: String?, onSubgroup: (String?) -> Unit) { private fun CurrentGroup(
current: SavedGroup,
subgroups: List<String>,
subgroup: String?,
onSubgroup: (String?) -> Unit,
choices: List<List<String>>,
taken: Map<String, String>,
onChoice: (List<String>, String?) -> Unit,
audience: Boolean,
foreign: Boolean?,
onForeign: (Boolean?) -> Unit,
) {
ListCard(Icons.Rounded.CheckCircle, "Сейчас открыта") { ListCard(Icons.Rounded.CheckCircle, "Сейчас открыта") {
CardRow( CardRow(
avatar = avatarLabel(current.group), avatar = avatarLabel(current.group),
@@ -291,9 +342,113 @@ private fun CurrentGroup(current: SavedGroup, subgroups: List<String>, subgroup:
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
} }
// Long names ("Стилистика русского языка. Культура речи") wrap as chips instead of a button row.
choices.forEach { options ->
val mine = taken[choiceKey(options)]
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.padding(start = 20.dp, end = 20.dp, bottom = 12.dp),
) {
Text("Что вы изучаете", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
(listOf<String?>(null) + options).forEach { option ->
val on = mine == option
FilterChip(
selected = on,
onClick = { onChoice(options, option) },
label = { Text(option ?: "Всё") },
leadingIcon = if (on) ({ Icon(Icons.Rounded.Check, null, Modifier.size(FilterChipDefaults.IconSize)) }) else null,
)
}
}
}
}
if (audience) Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(start = 20.dp, end = 20.dp, bottom = 12.dp),
) {
Text("Иностранный студент", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
ConnectedChoice(
options = listOf<Pair<Boolean?, String>>(null to "Всё", true to "Да", false to "Нет"),
selected = foreign,
onSelect = onForeign,
modifier = Modifier.fillMaxWidth(),
)
}
} }
} }
internal fun foreignText(group: String): String =
"В группе $group часть пар только для иностранных студентов («Русский язык как иностранный»), а часть — только для остальных. " +
"Ответьте, и чужие пары скроются из расписания, виджета и напоминаний. Изменить можно в разделе «Группы»."
/** "Вы иностранный студент?": the answer is remembered for this group. */
@Composable
private fun ForeignDialog(group: String, onPick: (Boolean?) -> Unit) {
AlertDialog(
onDismissRequest = { onPick(null) },
icon = { Icon(Icons.Rounded.Groups, contentDescription = null) },
title = { Text("Вы иностранный студент?") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(foreignText(group), style = MaterialTheme.typography.bodyMedium)
Spacer(Modifier.height(8.dp))
listOf<Pair<Boolean?, String>>(true to "Да, иностранный", false to "Нет", null to "Показывать всё").forEach { (value, label) ->
Surface(
onClick = { onPick(value) },
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.fillMaxWidth(),
) {
Text(label, style = MaterialTheme.typography.titleSmallEmphasized, modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp))
}
}
}
},
confirmButton = {},
dismissButton = { TextButton(onClick = { onPick(null) }) { Text("Позже") } },
)
}
/** What a part of the group takes, in a sentence: "«Немецкий язык» или «Французский язык»". */
internal fun choiceText(group: String, options: List<String>): String =
"В группе $group часть пар идёт на выбор: ${options.joinToString(" или ") { "«$it»" }}. Выберите, что изучаете вы, — " +
"пары другого варианта скроются из расписания, виджета и напоминаний. Изменить можно в разделе «Группы»."
/** "Что вы изучаете": German or French, say; the choice is remembered for this group. */
@Composable
private fun ChoiceDialog(group: String, options: List<String>, onPick: (String?) -> Unit) {
AlertDialog(
onDismissRequest = { onPick(null) },
icon = { Icon(Icons.Rounded.Groups, contentDescription = null) },
title = { Text("Что вы изучаете?") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(choiceText(group, options), style = MaterialTheme.typography.bodyMedium)
Spacer(Modifier.height(8.dp))
(options + listOf<String?>(null)).forEach { option ->
Surface(
onClick = { onPick(option) },
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.fillMaxWidth(),
) {
Text(
option ?: "Показывать всё",
style = MaterialTheme.typography.titleSmallEmphasized,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
)
}
}
}
},
confirmButton = {},
dismissButton = { TextButton(onClick = { onPick(null) }) { Text("Позже") } },
)
}
/** "Ваша подгруппа": all of them, or one; the choice is remembered for this group. */ /** "Ваша подгруппа": all of them, or one; the choice is remembered for this group. */
@Composable @Composable
private fun SubgroupDialog(group: String, subgroups: List<String>, onPick: (String?) -> Unit) { private fun SubgroupDialog(group: String, subgroups: List<String>, onPick: (String?) -> Unit) {

View File

@@ -62,7 +62,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
@@ -127,10 +126,11 @@ fun rememberClock(): LocalTime {
@Composable @Composable
fun HomeScreen(vm: MainViewModel, nav: AppNav) { fun HomeScreen(vm: MainViewModel, nav: AppNav) {
val undo = LocalUndo.current
val timetable by vm.timetable.collectAsStateWithLifecycle() val timetable by vm.timetable.collectAsStateWithLifecycle()
val settings by vm.settings.collectAsStateWithLifecycle() val settings by vm.settings.collectAsStateWithLifecycle()
val status by vm.status.collectAsStateWithLifecycle() val status by vm.status.collectAsStateWithLifecycle()
var details by remember { mutableStateOf<Pair<Lesson, LocalDate>?>(null) } val openLesson = rememberLessonOpener(vm)
var editing by remember { mutableStateOf<Note?>(null) } var editing by remember { mutableStateOf<Note?>(null) }
var session by remember { mutableStateOf(false) } var session by remember { mutableStateOf(false) }
val changes by vm.changes.collectAsStateWithLifecycle() val changes by vm.changes.collectAsStateWithLifecycle()
@@ -180,7 +180,7 @@ fun HomeScreen(vm: MainViewModel, nav: AppNav) {
status.message?.let { Banner(it, offline = status.offline, onClose = vm::dismissMessage, onRetry = if (status.offline) vm::retry else null, action = if (status.needsGroup) "Выбрать группу" to nav.openGroups else null) } status.message?.let { Banner(it, offline = status.offline, onClose = vm::dismissMessage, onRetry = if (status.offline) vm::retry else null, action = if (status.needsGroup) "Выбрать группу" to nav.openGroups else null) }
val t = timetable val t = timetable
when { when {
t == null && status.loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { LoadingIndicator() } t == null && status.loading -> OverviewSkeleton()
t == null -> CenteredEmpty { t == null -> CenteredEmpty {
Empty( Empty(
"Расписание не загружено", "Расписание не загружено",
@@ -196,8 +196,10 @@ fun HomeScreen(vm: MainViewModel, nav: AppNav) {
Empty("Обзор недоступен", "Строки Word по дням недели — во вкладке «Расписание».", icon = Icons.Rounded.Description, action = "Открыть" to { nav.openDay(date) }) Empty("Обзор недоступен", "Строки Word по дням недели — во вкладке «Расписание».", icon = Icons.Rounded.Description, action = "Открыть" to { nav.openDay(date) })
} }
} }
else -> BoxWithConstraints(Modifier.fillMaxSize()) { // Pull down for the latest timetable, as on the schedule.
else -> ExpressiveRefresh(refreshing = status.loading, onRefresh = { vm.refresh() }) { BoxWithConstraints(Modifier.fillMaxSize()) {
val twoPane = twoPane(maxWidth) val twoPane = twoPane(maxWidth)
val intro = remember { vm.takeIntro() }
val overview: @Composable (Modifier) -> Unit = { listModifier -> val overview: @Composable (Modifier) -> Unit = { listModifier ->
val todaySlots = vm.slotsOn(t, date, subgroup) val todaySlots = vm.slotsOn(t, date, subgroup)
val next = (1..7).asSequence() val next = (1..7).asSequence()
@@ -225,10 +227,10 @@ fun HomeScreen(vm: MainViewModel, nav: AppNav) {
} }
} }
} }
item(key = "week") { AnimatedItem { WeekReport(vm, t, subgroup, date, clock) } } item(key = "week") { AnimatedItem { Entrance(0, intro) { WeekReport(vm, t, subgroup, date, clock) } } }
item(key = "session") { AnimatedItem { SessionCard(vm, t, onOpen = { session = true }) } } item(key = "session") { AnimatedItem { Entrance(1, intro) { SessionCard(vm, t, onOpen = { session = true }) } } }
item(key = "today-title") { AnimatedItem { DayTitle("Сегодня", date) } } item(key = "today-title") { AnimatedItem { Entrance(2, intro) { DayTitle("Сегодня", date) } } }
item(key = "today") { AnimatedItem { TodayCard(todaySlots, vm.placesOn(t, date, subgroup), clock, onOpen = { details = it to date }, onDay = { nav.openDay(date) }) } } item(key = "today") { AnimatedItem { Entrance(3, intro) { TodayCard(todaySlots, vm.placesOn(t, date, subgroup), clock, onOpen = { openLesson(it, date) }, onDay = { nav.openDay(date) }) } } }
item(key = "attendance") { AnimatedItem { AttendanceCard(vm, todaySlots) } } item(key = "attendance") { AnimatedItem { AttendanceCard(vm, todaySlots) } }
item(key = "tasks") { AnimatedItem { TasksCard(vm, onEdit = { editing = it }, onAll = nav.openPlans) } } item(key = "tasks") { AnimatedItem { TasksCard(vm, onEdit = { editing = it }, onAll = nav.openPlans) } }
if (next != null) { if (next != null) {
@@ -237,7 +239,7 @@ fun HomeScreen(vm: MainViewModel, nav: AppNav) {
item(key = "next") { item(key = "next") {
SectionCard(Icons.Rounded.EventAvailable, plural(slots.size, "пара", "пары", "пар").let { "${slots.size} $it" }) { SectionCard(Icons.Rounded.EventAvailable, plural(slots.size, "пара", "пары", "пар").let { "${slots.size} $it" }) {
DayPlaces(vm.placesOn(t, day, subgroup)) DayPlaces(vm.placesOn(t, day, subgroup))
slots.forEach { slot -> slot.lessons.forEach { CompactLesson(it, slot, onClick = { details = it to day }) } } slots.forEach { slot -> slot.lessons.forEach { CompactLesson(it, slot, onClick = { openLesson(it, day) }) } }
DayButton { nav.openDay(day) } DayButton { nav.openDay(day) }
} }
} }
@@ -248,10 +250,10 @@ fun HomeScreen(vm: MainViewModel, nav: AppNav) {
// a foldable half opened like a book puts one on each side of the fold. // a foldable half opened like a book puts one on each side of the fold.
if (twoPane) PaneRow(Modifier.fillMaxSize().padding(end = 16.dp), firstWidth = 440.dp, gap = 0.dp, first = { overview(it) }) { if (twoPane) PaneRow(Modifier.fillMaxSize().padding(end = 16.dp), firstWidth = 440.dp, gap = 0.dp, first = { overview(it) }) {
DetailPane(it) { DetailPane(it) {
WeekPane(vm, t, subgroup, onOpen = { lesson, day -> details = lesson to day }) WeekPane(vm, t, subgroup, onOpen = { lesson, day -> openLesson(lesson, day) })
} }
} else ContentColumn { overview(Modifier.fillMaxSize()) } } else ContentColumn { overview(Modifier.fillMaxSize()) }
} } }
} }
} }
if (changesOpen) changes?.let { record -> if (changesOpen) changes?.let { record ->
@@ -267,13 +269,10 @@ fun HomeScreen(vm: MainViewModel, nav: AppNav) {
editing = note, editing = note,
groupKey = note.group, groupKey = note.group,
onSave = { vm.saveNote(it); editing = null }, onSave = { vm.saveNote(it); editing = null },
onDelete = { vm.deleteNote(it.id); editing = null }, onDelete = { vm.deleteNote(it.id, undo); editing = null },
onDismiss = { editing = null }, onDismiss = { editing = null },
) )
} }
details?.let { lesson ->
ModalBottomSheet(onDismissRequest = { details = null }) { LessonDetails(lesson.first, timetable?.bells.orEmpty(), lesson.second, vm, onClose = { details = null }) }
}
} }
/** Shortcuts under the app bar, as in School Planner's overview. */ /** Shortcuts under the app bar, as in School Planner's overview. */
@@ -520,6 +519,7 @@ private fun FeaturedLesson(lesson: Lesson, slot: Slot, clock: LocalTime, onClick
Row( Row(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.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), .clickable(onClickLabel = "Подробнее", role = Role.Button, onClick = onClick),
) { ) {
@@ -537,7 +537,7 @@ private fun FeaturedLesson(lesson: Lesson, slot: Slot, clock: LocalTime, onClick
color = if (running) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.secondaryContainer, color = if (running) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.secondaryContainer,
contentColor = if (running) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSecondaryContainer, contentColor = if (running) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSecondaryContainer,
) { ) {
Text( RollingText(
if (running) "Идёт · ещё ${minutesLabel(minutes)}" else "Через ${minutesLabel(minutes)}", if (running) "Идёт · ещё ${minutesLabel(minutes)}" else "Через ${minutesLabel(minutes)}",
style = MaterialTheme.typography.labelLarge, style = MaterialTheme.typography.labelLarge,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
@@ -560,7 +560,10 @@ fun CompactLesson(lesson: Lesson, slot: Slot, onClick: (() -> Unit)?) {
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.then( .then(
if (onClick != null) Modifier.clip(MaterialTheme.shapes.medium).clickable(onClickLabel = "Подробнее", role = Role.Button, onClick = onClick) if (onClick != null) Modifier
.morphOrigin("compact|${slot.date}|${lesson.key}|${lesson.start}", MaterialTheme.colorScheme.card, 12.dp)
.clip(MaterialTheme.shapes.medium)
.clickable(onClickLabel = "Подробнее", role = Role.Button, onClick = onClick)
else Modifier, else Modifier,
), ),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,

View File

@@ -74,7 +74,19 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
} }
fun saveNote(note: Note) = viewModelScope.launch { app.notes.save(note) } fun saveNote(note: Note) = viewModelScope.launch { app.notes.save(note) }
fun deleteNote(id: String) = viewModelScope.launch { app.notes.delete(id) } /** Deletes at once and offers it back in [undo]; the photos go only when the offer is gone. */
fun deleteNote(id: String, undo: UndoHost? = null) = viewModelScope.launch {
val note = app.notes.remove(id) ?: return@launch
if (undo == null) {
app.notes.discard(note.photos)
return@launch
}
undo.offer(
if (note.task) "Задание удалено" else "Заметка удалена",
onUndo = { viewModelScope.launch { app.notes.save(note) } },
onGone = { app.notes.discard(note.photos) },
)
}
fun toggleNote(id: String) = viewModelScope.launch { app.notes.toggle(id) } fun toggleNote(id: String) = viewModelScope.launch { app.notes.toggle(id) }
/** 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. */
@@ -148,6 +160,16 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
SyncWorker.afterChange(getApplication()) SyncWorker.afterChange(getApplication())
} }
fun setChoice(options: List<String>, option: String?) = viewModelScope.launch {
app.settings.setChoice(options, option)
SyncWorker.afterChange(getApplication())
}
fun setForeign(foreign: Boolean?) = viewModelScope.launch {
app.settings.setForeign(foreign)
SyncWorker.afterChange(getApplication())
}
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) }
@@ -166,6 +188,14 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun setChangeAlerts(on: Boolean) = viewModelScope.launch { app.settings.setChangeAlerts(on) } fun setChangeAlerts(on: Boolean) = viewModelScope.launch { app.settings.setChangeAlerts(on) }
/** The overview's cards rise in once per launch, after the launch screen, not on every return. */
private var introShown = false
fun takeIntro(): Boolean = !introShown.also { introShown = true }
/** Lessons a republished document just added or changed; each card glows once. */
val fresh: StateFlow<Set<String>> = app.repository.fresh
fun freshShown(key: String) = app.repository.freshShown(key)
/** "Новое задание" from the launcher: the plans open with the add sheet. */ /** "Новое задание" from the launcher: the plans open with the add sheet. */
val newTask = kotlinx.coroutines.flow.MutableStateFlow(false) val newTask = kotlinx.coroutines.flow.MutableStateFlow(false)

View File

@@ -0,0 +1,261 @@
package org.xtimms.psu.schedule.ui
import androidx.activity.compose.PredictiveBackHandler
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.detectVerticalDragGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
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.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.lerp
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.layer.GraphicsLayer
import androidx.compose.ui.graphics.layer.drawLayer
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.graphics.rememberGraphicsLayer
import androidx.compose.ui.graphics.drawscope.scale
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.lerp
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
// The M3 container transform: a card grows into its details, the "+" button into the sheet of a
// new plan, and back. Bottom sheets live in windows of their own and cannot grow from a card, so
// these open in one overlay over the app (MorphOverlay) instead.
/** Where a transform starts: an element's bounds, its fill and corners and a picture of it. */
class MorphOrigin(val bounds: Rect, val color: Color, val radius: Dp, val picture: GraphicsLayer)
enum class MorphKind {
/** A page: the whole screen on a phone, a large panel on a tablet. */
Page,
/** A sheet from the bottom, with a handle to drag it away. */
Sheet,
}
class MorphEntry(val key: String?, val kind: MorphKind, val content: @Composable (close: () -> Unit) -> Unit)
@Stable
class MorphHost {
internal val origins = mutableMapOf<String, MorphOrigin>()
// The element the finger went down on, and when: the page grows from what was tapped.
internal var touched: String? = null
internal var touchedAt = 0L
var entry by mutableStateOf<MorphEntry?>(null)
internal set
internal var closing by mutableStateOf(false)
internal val progress = Animatable(0f)
/** Opens [content] growing out of the element tapped a moment ago, if any. */
fun open(kind: MorphKind, content: @Composable (close: () -> Unit) -> Unit) {
val key = touched?.takeIf { System.currentTimeMillis() - touchedAt < 1500 }
closing = false
entry = MorphEntry(key, kind, content)
}
fun close() {
if (entry != null) closing = true
}
}
val LocalMorph = staticCompositionLocalOf<MorphHost?> { null }
/**
* Marks a card or a button as a place a page or a sheet grows from. While its page is open the
* element itself is hidden: the page has taken its place.
*/
@Composable
fun Modifier.morphOrigin(key: String, color: Color, radius: Dp): Modifier {
val host = LocalMorph.current ?: return this
val picture = rememberGraphicsLayer()
DisposableEffect(host, key) { onDispose { host.origins.remove(key) } }
val hidden = host.entry?.key == key
return this
.onGloballyPositioned { host.origins[key] = MorphOrigin(it.boundsInRoot(), color, radius, picture) }
.pointerInput(host, key) {
awaitEachGesture {
awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
host.touched = key
host.touchedAt = System.currentTimeMillis()
}
}
.drawWithContent {
picture.record { this@drawWithContent.drawContent() }
if (!hidden) drawLayer(picture)
}
}
/**
* The open page or sheet over the app. Opening, it grows from its origin (or from the middle):
* the origin's picture fades out over the first third, the content fades in after it. Back,
* a tap on the scrim or the predictive back gesture shrink it back the same way.
*/
@Composable
fun MorphOverlay(host: MorphHost) {
val entry = host.entry ?: return
val reduced = rememberReducedMotion()
val scope = rememberCoroutineScope()
var back by remember { mutableFloatStateOf(0f) }
var drag by remember { mutableFloatStateOf(0f) }
var rootOffset by remember { mutableStateOf(Offset.Zero) }
LaunchedEffect(entry) {
back = 0f
drag = 0f
host.progress.snapTo(0f)
host.progress.animateTo(1f, tween(if (reduced) 0 else 450, easing = Emphasized))
}
LaunchedEffect(host.closing) {
if (!host.closing) return@LaunchedEffect
host.progress.animateTo(0f, tween(if (reduced) 0 else 350, easing = Emphasized))
host.entry = null
host.closing = false
}
PredictiveBackHandler(enabled = !host.closing) { events ->
try {
events.collect { back = it.progress }
host.close()
} catch (e: CancellationException) {
scope.launch { back = 0f }
throw e
}
}
val density = LocalDensity.current
val surface = MaterialTheme.colorScheme.surfaceContainerLow
BoxWithConstraints(Modifier.fillMaxSize().onGloballyPositioned { rootOffset = it.positionInRoot() }) {
val w = constraints.maxWidth.toFloat()
val h = constraints.maxHeight.toFloat()
val wide = maxWidth >= 600.dp
val margin = with(density) { 24.dp.toPx() }
val target = when (entry.kind) {
MorphKind.Page -> if (wide) {
val pw = minOf(w - 2 * margin, with(density) { 680.dp.toPx() })
Rect(Offset((w - pw) / 2, margin), androidx.compose.ui.geometry.Size(pw, h - 2 * margin))
} else Rect(0f, 0f, w, h)
MorphKind.Sheet -> {
val sw = minOf(w, with(density) { 640.dp.toPx() })
val sh = h * 0.82f
Rect(Offset((w - sw) / 2, h - sh), androidx.compose.ui.geometry.Size(sw, sh))
}
}
val origin = entry.key?.let { host.origins[it] }
val from = origin?.bounds?.translate(-rootOffset) ?: Rect(target.center, 0f).inflate(minOf(target.width, target.height) * 0.3f)
val p = host.progress.value
val rect = lerp(from, target, p)
val targetRadius = when {
entry.kind == MorphKind.Sheet -> 28.dp
wide -> 28.dp
else -> 0.dp
}
val radius = lerp(origin?.radius ?: 28.dp, targetRadius, p)
val shape = if (entry.kind == MorphKind.Sheet) RoundedCornerShape(radius, radius, lerp(origin?.radius ?: 28.dp, 0.dp, p), lerp(origin?.radius ?: 28.dp, 0.dp, p))
else RoundedCornerShape(radius)
// The scrim; a tap on it closes.
Box(
Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.32f * p))
.pointerInput(host) { detectTapGestures { host.close() } },
)
Layout(
content = {
// The content, laid out once at its final size, revealed by the growing bounds.
Box(Modifier.graphicsLayer { alpha = ((p - 0.3f) / 0.7f).coerceIn(0f, 1f) }) {
if (entry.kind == MorphKind.Sheet) SheetChrome(onDrag = { drag = (drag + it).coerceAtLeast(0f) }, onRelease = {
if (drag > with(density) { 120.dp.toPx() }) host.close() else drag = 0f
}) { entry.content(host::close) }
else entry.content(host::close)
}
// The origin's own picture, fading out as the page takes over.
if (origin != null) Box(
Modifier
.graphicsLayer { alpha = (1f - p / 0.3f).coerceIn(0f, 1f) }
.drawBehind {
val s = rect.width / origin.bounds.width.coerceAtLeast(1f)
scale(s, s, pivot = Offset.Zero) { drawLayer(origin.picture) }
},
)
},
modifier = Modifier
.offset { IntOffset(rect.left.roundToInt(), rect.top.roundToInt()) }
.graphicsLayer {
val s = 1f - 0.08f * back
scaleX = s
scaleY = s
translationY = drag
}
.clip(shape)
.background(lerp(origin?.color ?: surface, surface, (p / 0.5f).coerceIn(0f, 1f)))
// Taps inside the page never reach the scrim under it.
.pointerInput(Unit) { detectTapGestures { } },
) { measurables, _ ->
val content = measurables[0].measure(Constraints.fixed(target.width.roundToInt(), target.height.roundToInt()))
val picture = measurables.getOrNull(1)?.measure(Constraints.fixed(rect.width.roundToInt().coerceAtLeast(0), rect.height.roundToInt().coerceAtLeast(0)))
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)
}
}
}
}
/** A sheet's handle, which drags the sheet down and away. */
@Composable
private fun SheetChrome(onDrag: (Float) -> Unit, onRelease: () -> Unit, content: @Composable () -> Unit) {
Column(Modifier.fillMaxSize()) {
Box(
Modifier
.fillMaxWidth()
.height(36.dp)
.pointerInput(Unit) { detectVerticalDragGestures(onDragEnd = onRelease, onDragCancel = onRelease) { _, dy -> onDrag(dy) } },
contentAlignment = Alignment.Center,
) {
Surface(shape = RoundedCornerShape(2.dp), color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), modifier = Modifier.size(32.dp, 4.dp)) {}
}
Box(Modifier.weight(1f)) { content() }
}
}

View File

@@ -28,6 +28,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.draw.drawWithContent
// Material motion patterns, as in RideBus: shared axis X between a screen and the page it // Material motion patterns, as in RideBus: shared axis X between a screen and the page it
// opens, fade through between top-level destinations. // opens, fade through between top-level destinations.
@@ -103,3 +105,130 @@ fun LazyStaggeredGridItemScope.AnimatedItem(content: @Composable () -> Unit) {
fun materialFadeThrough(): ContentTransform = fun materialFadeThrough(): ContentTransform =
(fadeIn(tween(400, easing = EmphasizedDecelerate)) + scaleIn(tween(400, easing = EmphasizedDecelerate), initialScale = 0.92f)) togetherWith (fadeIn(tween(400, easing = EmphasizedDecelerate)) + scaleIn(tween(400, easing = EmphasizedDecelerate), initialScale = 0.92f)) togetherWith
fadeOut(tween(200, easing = EmphasizedAccelerate)) fadeOut(tween(200, easing = EmphasizedAccelerate))
/**
* Whether the student turned animations off ("Убрать анимацию" sets the animator scale to 0):
* decorative motion is skipped then.
*/
@Composable
fun rememberReducedMotion(): Boolean {
val resolver = androidx.compose.ui.platform.LocalContext.current.contentResolver
return remember {
android.provider.Settings.Global.getFloat(resolver, android.provider.Settings.Global.ANIMATOR_DURATION_SCALE, 1f) == 0f
}
}
/**
* The overview's cards after the launch screen: each rises 24dp and fades in, a little after
* the one above it. Only on [play] (the first overview of a launch), never with reduced motion.
*/
@Composable
fun Entrance(index: Int, play: Boolean, content: @Composable () -> Unit) {
val reduced = rememberReducedMotion()
if (!play || reduced) {
content()
return
}
val shown = remember { androidx.compose.animation.core.Animatable(0f) }
androidx.compose.runtime.LaunchedEffect(Unit) {
kotlinx.coroutines.delay(60L * index)
shown.animateTo(1f, tween(MotionDuration.Medium4, easing = EmphasizedDecelerate))
}
Box(
Modifier.graphicsLayer {
alpha = shown.value
translationY = (1f - shown.value) * 24.dp.toPx()
},
) { content() }
}
/**
* A short changing label ("ещё 25 мин" → "ещё 24 мин") that rolls up to its new value instead
* of flicking; still with animations turned off.
*/
@Composable
fun RollingText(text: String, style: androidx.compose.ui.text.TextStyle, modifier: Modifier = Modifier) {
if (rememberReducedMotion()) {
androidx.compose.material3.Text(text, style = style, modifier = modifier)
return
}
androidx.compose.animation.AnimatedContent(
targetState = text,
transitionSpec = {
(androidx.compose.animation.slideInVertically(tween(MotionDuration.Short4, easing = EmphasizedDecelerate)) { it } + fadeIn(tween(MotionDuration.Short4))) togetherWith
(androidx.compose.animation.slideOutVertically(tween(MotionDuration.Short4, easing = EmphasizedAccelerate)) { -it } + fadeOut(tween(MotionDuration.Short4))) using
androidx.compose.animation.SizeTransform(clip = true)
},
label = "rolling",
modifier = modifier,
) { androidx.compose.material3.Text(it, style = style) }
}
/**
* The lesson under way: a line runs around the card's edge as the pair goes, from its top-left
* corner clockwise, over a faint full outline. [progress] is 0..1.
*/
fun Modifier.progressBorder(progress: Float, color: androidx.compose.ui.graphics.Color, radius: androidx.compose.ui.unit.Dp): Modifier =
drawWithContent {
drawContent()
val stroke = 3.dp.toPx()
val inset = stroke / 2
val r = radius.toPx() - inset
// Round the card clockwise from just after its top-left corner, so the line starts there.
val l = inset
val t = inset
val rr = size.width - inset
val b = size.height - inset
val path = androidx.compose.ui.graphics.Path().apply {
moveTo(l + r, t)
lineTo(rr - r, t)
arcTo(androidx.compose.ui.geometry.Rect(rr - 2 * r, t, rr, t + 2 * r), -90f, 90f, false)
lineTo(rr, b - r)
arcTo(androidx.compose.ui.geometry.Rect(rr - 2 * r, b - 2 * r, rr, b), 0f, 90f, false)
lineTo(l + r, b)
arcTo(androidx.compose.ui.geometry.Rect(l, b - 2 * r, l + 2 * r, b), 90f, 90f, false)
lineTo(l, t + r)
arcTo(androidx.compose.ui.geometry.Rect(l, t, l + 2 * r, t + 2 * r), 180f, 90f, false)
close()
}
drawPath(path, color.copy(alpha = 0.22f), style = androidx.compose.ui.graphics.drawscope.Stroke(width = 2.dp.toPx()))
val measure = androidx.compose.ui.graphics.PathMeasure().apply { setPath(path, false) }
val done = androidx.compose.ui.graphics.Path()
measure.getSegment(0f, measure.length * progress.coerceIn(0f, 1f), done, true)
drawPath(done, color, style = androidx.compose.ui.graphics.drawscope.Stroke(width = stroke, cap = androidx.compose.ui.graphics.StrokeCap.Round))
}
/**
* Pull to refresh with the M3 Expressive loading indicator, its shape morphing while the
* timetable loads; a tick under the finger when the pull is far enough to refresh.
*/
@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class, androidx.compose.material3.ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ExpressiveRefresh(
refreshing: Boolean,
onRefresh: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable androidx.compose.foundation.layout.BoxScope.() -> Unit,
) {
val state = androidx.compose.material3.pulltorefresh.rememberPullToRefreshState()
val haptic = androidx.compose.ui.platform.LocalHapticFeedback.current
val busy = androidx.compose.runtime.rememberUpdatedState(refreshing)
androidx.compose.runtime.LaunchedEffect(state) {
androidx.compose.runtime.snapshotFlow { state.distanceFraction >= 1f }
.collect { far -> if (far && !busy.value) haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.GestureThresholdActivate) }
}
androidx.compose.material3.pulltorefresh.PullToRefreshBox(
isRefreshing = refreshing,
onRefresh = onRefresh,
state = state,
modifier = modifier,
indicator = {
androidx.compose.material3.pulltorefresh.PullToRefreshDefaults.LoadingIndicator(
state = state,
isRefreshing = refreshing,
modifier = Modifier.align(androidx.compose.ui.Alignment.TopCenter),
)
},
content = content,
)
}

View File

@@ -2,6 +2,11 @@
package org.xtimms.psu.schedule.ui package org.xtimms.psu.schedule.ui
import kotlinx.coroutines.launch
import kotlinx.coroutines.delay
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material.icons.rounded.CalendarToday import androidx.compose.material.icons.rounded.CalendarToday
@@ -9,7 +14,13 @@ import androidx.compose.material3.rememberDatePickerState
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.DatePickerDialog import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.DatePicker import androidx.compose.material3.DatePicker
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.rounded.Image
import androidx.compose.ui.platform.LocalContext
import org.xtimms.psu.schedule.app
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.Column import androidx.compose.foundation.layout.Column
@@ -111,6 +122,11 @@ fun MainViewModel.nextOccurrence(t: Timetable, subject: String, after: LocalDate
@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) {
val overdue = note.overdue() val overdue = note.overdue()
var viewing by remember { mutableStateOf(false) }
val haptic = LocalHapticFeedback.current
// A ticked task is struck through in place first, then moves down to the done ones.
var shownDone by remember(note.id, note.done) { mutableStateOf(note.done) }
val scope = rememberCoroutineScope()
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier modifier = Modifier
@@ -119,20 +135,20 @@ 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 = note.done, onCheckedChange = { onToggle() }) if (note.task) Checkbox(checked = shownDone, onCheckedChange = { done ->
haptic.performHapticFeedback(if (done) HapticFeedbackType.Confirm else HapticFeedbackType.ToggleOff)
shownDone = done
if (done) scope.launch {
delay(DONE_PAUSE)
onToggle()
} else onToggle()
})
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)
} }
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Column(Modifier.weight(1f).padding(vertical = 8.dp)) { Column(Modifier.weight(1f).padding(vertical = 8.dp)) {
Text( StrikeText(note.text, done = note.task && shownDone, style = MaterialTheme.typography.bodyLarge, maxLines = 3)
note.text,
style = MaterialTheme.typography.bodyLarge,
textDecoration = if (note.task && note.done) TextDecoration.LineThrough else null,
color = if (note.task && note.done) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
if (showSubject && !note.general) { if (showSubject && !note.general) {
SubjectDot(note.subject, 8.dp) SubjectDot(note.subject, 8.dp)
@@ -155,7 +171,22 @@ fun NoteRow(note: Note, onToggle: () -> Unit, onClick: () -> Unit, showSubject:
) )
} }
} }
// The first photo, with how many more; it opens the viewer, the row the editor.
if (note.photos.isNotEmpty()) Box(Modifier.padding(start = 12.dp)) {
PhotoThumb(note.photos.first(), 48.dp, onClick = { viewing = true })
if (note.photos.size > 1) Text(
"+${note.photos.size - 1}",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.inverseOnSurface,
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(3.dp)
.background(MaterialTheme.colorScheme.inverseSurface.copy(alpha = 0.85f), MaterialTheme.shapes.small)
.padding(horizontal = 5.dp),
)
}
} }
if (viewing) PhotoViewer(note.photos, 0) { viewing = false }
} }
/** What the editor needs to file a new note: the lesson it opened from and the next one. */ /** What the editor needs to file a new note: the lesson it opened from and the next one. */
@@ -182,11 +213,19 @@ fun NoteEditor(
val general = editing?.general ?: (target?.subject?.isBlank() == true) val general = editing?.general ?: (target?.subject?.isBlank() == true)
var day by rememberSaveable { mutableStateOf((editing?.date?.let(::parseDate) ?: target?.date ?: today()).toEpochDay()) } var day by rememberSaveable { mutableStateOf((editing?.date?.let(::parseDate) ?: target?.date ?: today()).toEpochDay()) }
var picking by remember { mutableStateOf(false) } var picking by remember { mutableStateOf(false) }
// Photos of this edit; the ones added here go from the disk again if it is not saved.
var photos by rememberSaveable { mutableStateOf(editing?.photos.orEmpty()) }
var added by rememberSaveable { mutableStateOf(emptyList<String>()) }
val store = LocalContext.current.app.notes
val cancel = {
store.discard(added)
onDismiss()
}
val focus = remember { FocusRequester() } val focus = remember { FocusRequester() }
LaunchedEffect(Unit) { focus.requestFocus() } LaunchedEffect(Unit) { focus.requestFocus() }
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = cancel,
icon = { Icon(if (task) Icons.AutoMirrored.Rounded.Assignment else Icons.Rounded.StickyNote2, contentDescription = null) }, icon = { Icon(if (task) Icons.AutoMirrored.Rounded.Assignment else Icons.Rounded.StickyNote2, contentDescription = null) },
title = { title = {
Text( Text(
@@ -198,7 +237,7 @@ fun NoteEditor(
) )
}, },
text = { text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(verticalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.verticalScroll(rememberScrollState())) {
ConnectedChoice( ConnectedChoice(
options = listOf(true to "Задание", false to "Заметка"), options = listOf(true to "Задание", false to "Заметка"),
selected = task, selected = task,
@@ -212,6 +251,19 @@ fun NoteEditor(
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences), keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences),
modifier = Modifier.fillMaxWidth().heightIn(min = 96.dp).focusRequester(focus), modifier = Modifier.fillMaxWidth().heightIn(min = 96.dp).focusRequester(focus),
) )
// A photo of the board or the assignment instead of copying it out.
NotePhotosEditor(
photos = photos,
onAdd = { photos = photos + it; added = added + it },
onRemove = { name ->
photos = photos - name
// A photo added in this edit has no saved note to keep it.
if (name in added) {
added = added - name
store.discard(listOf(name))
}
},
)
if (general) { if (general) {
Text("День", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant) Text("День", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
OutlinedButton(onClick = { picking = true }, modifier = Modifier.fillMaxWidth()) { OutlinedButton(onClick = { picking = true }, modifier = Modifier.fillMaxWidth()) {
@@ -241,7 +293,8 @@ fun NoteEditor(
}, },
confirmButton = { confirmButton = {
TextButton( TextButton(
enabled = text.isNotBlank(), // A photo alone is a note too: "задание на фото".
enabled = text.isNotBlank() || photos.isNotEmpty(),
onClick = { onClick = {
val base = editing ?: run { val base = editing ?: run {
val t = target ?: return@TextButton val t = target ?: return@TextButton
@@ -249,16 +302,16 @@ fun NoteEditor(
Note(group = groupKey, subject = t.subject, date = date.toString(), start = start, text = "") Note(group = groupKey, subject = t.subject, date = date.toString(), start = start, text = "")
} }
val dated = if (general) base.copy(date = LocalDate.ofEpochDay(day).toString()) else base val dated = if (general) base.copy(date = LocalDate.ofEpochDay(day).toString()) else base
onSave(dated.copy(text = text.trim(), task = task)) onSave(dated.copy(text = text.trim().ifEmpty { "Фото" }, task = task, photos = photos))
}, },
) { Text("Сохранить") } ) { Text("Сохранить") }
}, },
dismissButton = { dismissButton = {
Row { Row {
if (editing != null) TextButton(onClick = { onDelete(editing) }) { if (editing != null) TextButton(onClick = { store.discard(added); onDelete(editing) }) {
Text("Удалить", color = MaterialTheme.colorScheme.error) Text("Удалить", color = MaterialTheme.colorScheme.error)
} }
TextButton(onClick = onDismiss) { Text("Отмена") } TextButton(onClick = cancel) { Text("Отмена") }
} }
}, },
) )
@@ -280,6 +333,7 @@ fun NoteEditor(
/** The lesson sheet's section: this lesson's tasks and notes and a button to add one. */ /** The lesson sheet's section: this lesson's tasks and notes and a button to add one. */
@Composable @Composable
fun LessonNotes(vm: MainViewModel, lesson: Lesson, date: LocalDate) { fun LessonNotes(vm: MainViewModel, lesson: Lesson, date: LocalDate) {
val undo = LocalUndo.current
val all by vm.notes.collectAsStateWithLifecycle() val all by vm.notes.collectAsStateWithLifecycle()
val timetable by vm.timetable.collectAsStateWithLifecycle() val timetable by vm.timetable.collectAsStateWithLifecycle()
val settings by vm.settings.collectAsStateWithLifecycle() val settings by vm.settings.collectAsStateWithLifecycle()
@@ -317,7 +371,7 @@ fun LessonNotes(vm: MainViewModel, lesson: Lesson, date: LocalDate) {
editing = editing, editing = editing,
groupKey = group, groupKey = group,
onSave = { vm.saveNote(it); adding = false; editing = null }, onSave = { vm.saveNote(it); adding = false; editing = null },
onDelete = { vm.deleteNote(it.id); editing = null }, onDelete = { vm.deleteNote(it.id, undo); editing = null },
onDismiss = { adding = false; editing = null }, onDismiss = { adding = false; editing = null },
) )
} }
@@ -342,6 +396,11 @@ fun NotesHint(notes: List<Note>, color: androidx.compose.ui.graphics.Color) {
color = color, color = color,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
) )
if (open.any { it.photos.isNotEmpty() }) {
Spacer(Modifier.width(6.dp))
Icon(Icons.Rounded.Image, contentDescription = "С фото", tint = color, modifier = Modifier.size(18.dp))
}
} }
} }

View File

@@ -51,6 +51,8 @@ import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
import androidx.compose.material.icons.rounded.CalendarMonth import androidx.compose.material.icons.rounded.CalendarMonth
import androidx.compose.material.icons.rounded.Checklist import androidx.compose.material.icons.rounded.Checklist
import androidx.compose.material.icons.rounded.Groups import androidx.compose.material.icons.rounded.Groups
import androidx.compose.material.icons.rounded.Check
import org.xtimms.psu.schedule.data.choiceKey
import androidx.compose.material.icons.rounded.NotificationsActive import androidx.compose.material.icons.rounded.NotificationsActive
import androidx.compose.material.icons.rounded.DownloadForOffline import androidx.compose.material.icons.rounded.DownloadForOffline
import androidx.compose.material3.Button import androidx.compose.material3.Button
@@ -93,14 +95,15 @@ import org.xtimms.psu.schedule.ui.theme.page
import kotlin.coroutines.cancellation.CancellationException import kotlin.coroutines.cancellation.CancellationException
/** 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, Reminders } private enum class Step { Welcome, Group, Subgroup, Choice, Foreign, Reminders }
/** Text and controls of the onboarding pages keep to a phone's width on tablets. */ /** Text and controls of the onboarding pages keep to a phone's width on tablets. */
private val PAGE_WIDTH = 560.dp private val PAGE_WIDTH = 560.dp
/** /**
* First-run onboarding. Only the group is required: the welcome leads straight to it, * First-run onboarding. Only the group is required: the welcome leads straight to it,
* the subgroup page appears only for groups split into subgroups, and reminders can wait. * the subgroup page appears only for groups split into subgroups, the choice page only for groups
* whose parts study one subject or another (German or French), and reminders can wait.
* [onDone] opens the overview with the chosen group. * [onDone] opens the overview with the chosen group.
*/ */
@Composable @Composable
@@ -108,14 +111,18 @@ fun Onboarding(vm: MainViewModel, onDone: () -> Unit) {
val timetable by vm.timetable.collectAsStateWithLifecycle() val timetable by vm.timetable.collectAsStateWithLifecycle()
val settings by vm.settings.collectAsStateWithLifecycle() val settings by vm.settings.collectAsStateWithLifecycle()
val subgroups = timetable?.subgroups.orEmpty() val subgroups = timetable?.subgroups.orEmpty()
val choices = timetable?.choices.orEmpty()
var step by rememberSaveable { mutableStateOf(Step.Welcome) } var step by rememberSaveable { mutableStateOf(Step.Welcome) }
// The steps the progress bar counts; the welcome is not one of them. // The steps the progress bar counts; the welcome is not one of them.
val steps = if (subgroups.isEmpty()) listOf(Step.Group, Step.Reminders) else listOf(Step.Group, Step.Subgroup, Step.Reminders) val steps = listOfNotNull(
fun previous(of: Step): Step = when (of) { Step.Group,
Step.Welcome, Step.Group -> Step.Welcome Step.Subgroup.takeIf { subgroups.isNotEmpty() },
Step.Subgroup -> Step.Group Step.Choice.takeIf { choices.isNotEmpty() },
Step.Reminders -> if (subgroups.isEmpty()) Step.Group else Step.Subgroup Step.Foreign.takeIf { timetable?.audience == true },
} Step.Reminders,
)
fun previous(of: Step): Step = if (of == Step.Welcome) Step.Welcome else steps.getOrNull(steps.indexOf(of) - 1) ?: Step.Welcome
fun next(of: Step): Step = steps.getOrNull(steps.indexOf(of) + 1) ?: Step.Reminders
// Predictive back, as in the rest of the app: the gesture scrubs the way back to the previous step. // Predictive back, as in the rest of the app: the gesture scrubs the way back to the previous step.
val state = remember { SeekableTransitionState(step) } val state = remember { SeekableTransitionState(step) }
@@ -162,7 +169,16 @@ fun Onboarding(vm: MainViewModel, onDone: () -> Unit) {
Step.Welcome -> Welcome(onStart = { step = Step.Group }) Step.Welcome -> Welcome(onStart = { step = Step.Group })
Step.Group -> GroupsScreen( Step.Group -> GroupsScreen(
vm, vm,
onOpened = { step = if (vm.timetable.value?.subgroups.isNullOrEmpty()) Step.Reminders else Step.Subgroup }, // The steps above still count the previous group's timetable: read the new one.
onOpened = {
val t = vm.timetable.value
step = when {
!t?.subgroups.isNullOrEmpty() -> Step.Subgroup
!t?.choices.isNullOrEmpty() -> Step.Choice
t?.audience == true -> Step.Foreign
else -> Step.Reminders
}
},
onBack = null, onBack = null,
onboarding = true, onboarding = true,
) )
@@ -170,7 +186,18 @@ fun Onboarding(vm: MainViewModel, onDone: () -> Unit) {
group = timetable?.group.orEmpty(), group = timetable?.group.orEmpty(),
subgroups = subgroups, subgroups = subgroups,
selected = settings?.subgroup, selected = settings?.subgroup,
onPick = { vm.setSubgroup(it); step = Step.Reminders }, onPick = { vm.setSubgroup(it); step = next(Step.Subgroup) },
)
Step.Choice -> ChoiceStep(
group = timetable?.group.orEmpty(),
choices = choices,
taken = settings?.choices.orEmpty(),
onPick = vm::setChoice,
onNext = { step = next(Step.Choice) },
)
Step.Foreign -> ForeignStep(
group = timetable?.group.orEmpty(),
onPick = { vm.setForeign(it); step = Step.Reminders },
) )
Step.Reminders -> RemindersStep( Step.Reminders -> RemindersStep(
minutes = settings?.reminderMinutes ?: 10, minutes = settings?.reminderMinutes ?: 10,
@@ -441,6 +468,75 @@ private fun SubgroupStep(group: String, subgroups: List<String>, selected: Strin
} }
} }
/**
* "Что вы изучаете": German or French, say. With one choice a tap picks and moves on; with
* several, each list keeps its pick and "Далее" moves on.
*/
@Composable
private fun ChoiceStep(
group: String,
choices: List<List<String>>,
taken: Map<String, String>,
onPick: (List<String>, String?) -> Unit,
onNext: () -> Unit,
) {
StepPage(
centered = false,
buttons = {
if (choices.size > 1) PrimaryButton("Далее", onNext)
TextButton(onClick = onNext) { Text("Пропустить") }
},
) {
StepHeading(Icons.Rounded.Groups, "Что вы изучаете?", choiceText(group, choices.first()))
choices.forEachIndexed { n, options ->
if (n > 0) Spacer(Modifier.height(16.dp))
SegmentedList {
val all = options + listOf<String?>(null)
all.forEachIndexed { i, option ->
SegmentedItem(
index = i,
count = all.size,
headline = option ?: "Показывать всё",
supporting = if (option == null) "Занятия всех вариантов" else null,
onClick = {
onPick(options, option)
if (choices.size == 1) onNext()
},
trailing = {
if (choices.size > 1 && taken[choiceKey(options)] == option) Icon(Icons.Rounded.Check, contentDescription = "Выбрано")
else Icon(Icons.AutoMirrored.Rounded.KeyboardArrowRight, contentDescription = null)
},
)
}
}
}
}
}
/** "Вы иностранный студент?" as a page: one tap answers and moves on. */
@Composable
private fun ForeignStep(group: String, onPick: (Boolean?) -> Unit) {
StepPage(
centered = false,
buttons = { TextButton(onClick = { onPick(null) }) { Text("Пропустить") } },
) {
StepHeading(Icons.Rounded.Groups, "Вы иностранный студент?", foreignText(group))
SegmentedList {
val answers = listOf<Pair<Boolean?, String>>(true to "Да, иностранный", false to "Нет", null to "Показывать всё")
answers.forEachIndexed { i, (value, label) ->
SegmentedItem(
index = i,
count = answers.size,
headline = label,
supporting = if (value == null) "Пары для всех студентов" else null,
onClick = { onPick(value) },
trailing = { Icon(Icons.AutoMirrored.Rounded.KeyboardArrowRight, contentDescription = null) },
)
}
}
}
}
/** Reminders before each lesson; Android asks for the notification permission here, when it makes sense. */ /** Reminders before each lesson; Android asks for the notification permission here, when it makes sense. */
@Composable @Composable
private fun RemindersStep(minutes: Int, onMinutes: (Int) -> Unit, onDone: (Boolean) -> Unit) { private fun RemindersStep(minutes: Int, onMinutes: (Int) -> Unit, onDone: (Boolean) -> Unit) {

View File

@@ -0,0 +1,274 @@
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

@@ -28,6 +28,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
@@ -119,22 +120,33 @@ private sealed interface Row {
*/ */
@Composable @Composable
fun PlansScreen(vm: MainViewModel, nav: AppNav) { fun PlansScreen(vm: MainViewModel, nav: AppNav) {
val undo = LocalUndo.current
val all by vm.notes.collectAsStateWithLifecycle() val all by vm.notes.collectAsStateWithLifecycle()
val timetable by vm.timetable.collectAsStateWithLifecycle() val timetable by vm.timetable.collectAsStateWithLifecycle()
val settings by vm.settings.collectAsStateWithLifecycle() val settings by vm.settings.collectAsStateWithLifecycle()
val prefs = settings?.plans ?: PlansPrefs() val prefs = settings?.plans ?: PlansPrefs()
var editing by remember { mutableStateOf<Note?>(null) } var editing by remember { mutableStateOf<Note?>(null) }
var adding by remember { mutableStateOf(false) } var target by remember { mutableStateOf<NoteTarget?>(null) }
// "+" grows into the sheet of a new plan (Morph.kt); a shortcut opens it from the middle.
val morph = LocalMorph.current
val addPlan = {
morph?.open(MorphKind.Sheet) { close ->
AddPlanSheet(vm, timetable, settings?.subgroup, day = null) {
close()
target = it
}
}
Unit
}
val wantsTask by vm.newTask.collectAsStateWithLifecycle() val wantsTask by vm.newTask.collectAsStateWithLifecycle()
LaunchedEffect(wantsTask) { LaunchedEffect(wantsTask) {
if (wantsTask) { if (wantsTask) {
adding = true addPlan()
vm.newTask.value = false vm.newTask.value = false
} }
} }
var tuning by remember { mutableStateOf(false) } var tuning by remember { mutableStateOf(false) }
var details by remember { mutableStateOf<Pair<Lesson, LocalDate>?>(null) } val openLesson = rememberLessonOpener(vm)
var target by remember { mutableStateOf<NoteTarget?>(null) }
var date by remember { mutableStateOf(today()) } var date by remember { mutableStateOf(today()) }
// Each layout keeps its own list, so the outgoing and incoming layouts can be on screen // Each layout keeps its own list, so the outgoing and incoming layouts can be on screen
// together while one fades through to the other. // together while one fades through to the other.
@@ -174,11 +186,15 @@ fun PlansScreen(vm: MainViewModel, nav: AppNav) {
title = { title = {
Column { Column {
Text("Планы", style = MaterialTheme.typography.titleLargeEmphasized) Text("Планы", style = MaterialTheme.typography.titleLargeEmphasized)
Text( androidx.compose.foundation.layout.Row(verticalAlignment = Alignment.CenterVertically) {
if (open == 0) "Всё сделано" else "$open ${plural(open, "дело", "дела", "дел")} впереди", // The last task done: a check pops up by "Всё сделано".
style = MaterialTheme.typography.bodyMedium, DoneMark(open == 0)
color = MaterialTheme.colorScheme.onSurfaceVariant, Text(
) if (open == 0) "Всё сделано" else "$open ${plural(open, "дело", "дела", "дел")} впереди",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} }
}, },
actions = { actions = {
@@ -254,7 +270,7 @@ fun PlansScreen(vm: MainViewModel, nav: AppNav) {
DetailPane(it) { DetailPane(it) {
DayAgenda( DayAgenda(
vm, timetable, settings?.subgroup, date, all, vm, timetable, settings?.subgroup, date, all,
onLesson = { details = it to date }, onLesson = { openLesson(it, date) },
onNote = { editing = it }, onNote = { editing = it },
modifier = Modifier.fillMaxSize().padding(top = 12.dp), modifier = Modifier.fillMaxSize().padding(top = 12.dp),
) )
@@ -265,30 +281,22 @@ fun PlansScreen(vm: MainViewModel, nav: AppNav) {
} }
} }
FloatingActionButton( FloatingActionButton(
onClick = { adding = true }, onClick = addPlan,
modifier = Modifier.align(Alignment.BottomEnd).padding(end = 16.dp, bottom = 16.dp + navBarPadding()), modifier = Modifier
.align(Alignment.BottomEnd)
.padding(end = 16.dp, bottom = 16.dp + navBarPadding())
.morphOrigin("fab|plans", MaterialTheme.colorScheme.primaryContainer, 16.dp),
) { Icon(Icons.Rounded.Add, contentDescription = "Новое задание") } ) { Icon(Icons.Rounded.Add, contentDescription = "Новое задание") }
} }
details?.let { (lesson, d) ->
ModalBottomSheet(onDismissRequest = { details = null }) {
LessonDetails(lesson, timetable?.bells.orEmpty(), d, vm, onClose = { details = null })
}
}
if (tuning) ModalBottomSheet(onDismissRequest = { tuning = false }) { PlansSettings(prefs, vm::setPlans) } if (tuning) ModalBottomSheet(onDismissRequest = { tuning = false }) { PlansSettings(prefs, vm::setPlans) }
val group = vm.currentGroupKey() val group = vm.currentGroupKey()
if (adding) ModalBottomSheet(onDismissRequest = { adding = false }) {
AddPlanSheet(vm, timetable, settings?.subgroup, day = null) {
adding = false
target = it
}
}
if (group != null && (target != null || editing != null)) NoteEditor( if (group != null && (target != null || editing != null)) NoteEditor(
target = target, target = target,
editing = editing, editing = editing,
groupKey = editing?.group ?: group, groupKey = editing?.group ?: group,
onSave = { vm.saveNote(it); target = null; editing = null }, onSave = { vm.saveNote(it); target = null; editing = null },
onDelete = { vm.deleteNote(it.id); editing = null }, onDelete = { vm.deleteNote(it.id, undo); editing = null },
onDismiss = { target = null; editing = null }, onDismiss = { target = null; editing = null },
) )
} }
@@ -564,6 +572,7 @@ internal fun AddPlanSheet(vm: MainViewModel, t: Timetable?, subgroup: String?, d
Column( Column(
Modifier Modifier
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.navigationBarsPadding()
.padding(start = 24.dp, end = 24.dp, bottom = 32.dp), .padding(start = 24.dp, end = 24.dp, bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(10.dp), verticalArrangement = Arrangement.spacedBy(10.dp),
) { ) {

View File

@@ -2,6 +2,10 @@
package org.xtimms.psu.schedule.ui package org.xtimms.psu.schedule.ui
import androidx.compose.animation.core.tween
import kotlinx.coroutines.flow.drop
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.animation.togetherWith import androidx.compose.animation.togetherWith
import androidx.compose.animation.Crossfade import androidx.compose.animation.Crossfade
@@ -19,6 +23,7 @@ import androidx.compose.foundation.interaction.collectIsDraggedAsState
import androidx.compose.material.icons.rounded.VisibilityOff import androidx.compose.material.icons.rounded.VisibilityOff
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import org.xtimms.psu.schedule.data.hideKey import org.xtimms.psu.schedule.data.hideKey
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
@@ -33,6 +38,7 @@ 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
@@ -81,16 +87,15 @@ 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.LoadingIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet 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.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.foundation.lazy.rememberLazyListState
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
@@ -128,12 +133,12 @@ fun ScheduleScreen(
val timetable by vm.timetable.collectAsStateWithLifecycle() val timetable by vm.timetable.collectAsStateWithLifecycle()
val settings by vm.settings.collectAsStateWithLifecycle() val settings by vm.settings.collectAsStateWithLifecycle()
val status by vm.status.collectAsStateWithLifecycle() val status by vm.status.collectAsStateWithLifecycle()
var details by remember { mutableStateOf<Pair<Lesson, LocalDate?>?>(null) } val openLesson = rememberLessonOpener(vm)
val subgroup = settings?.subgroup val subgroup = settings?.subgroup
val t = timetable val t = timetable
if (t?.semester != null) { if (t?.semester != null) {
Planner(vm, nav, t, subgroup, weekView, onWeekView, focus, onFocusHandled, onOpen = { lesson, date -> details = lesson to date }) Planner(vm, nav, t, subgroup, weekView, onWeekView, focus, onFocusHandled, onOpen = { lesson, date -> openLesson(lesson, date) })
} else Column(Modifier.fillMaxSize()) { } else Column(Modifier.fillMaxSize()) {
TopAppBar( TopAppBar(
colors = appBarColors(), colors = appBarColors(),
@@ -143,7 +148,7 @@ fun ScheduleScreen(
) )
status.message?.let { Banner(it, offline = status.offline, onClose = vm::dismissMessage, onRetry = if (status.offline) vm::retry else null, action = if (status.needsGroup) "Выбрать группу" to nav.openGroups else null) } status.message?.let { Banner(it, offline = status.offline, onClose = vm::dismissMessage, onRetry = if (status.offline) vm::retry else null, action = if (status.needsGroup) "Выбрать группу" to nav.openGroups else null) }
when { when {
t == null && status.loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { LoadingIndicator() } t == null && status.loading -> ScheduleSkeleton()
t == null -> CenteredEmpty { t == null -> CenteredEmpty {
Empty( Empty(
"Расписание не загружено", "Расписание не загружено",
@@ -153,12 +158,7 @@ fun ScheduleScreen(
action = if (settings?.current != null) "Повторить" to { vm.refresh(); Unit } else "Выбрать группу" to nav.openGroups, action = if (settings?.current != null) "Повторить" to { vm.refresh(); Unit } else "Выбрать группу" to nav.openGroups,
) )
} }
else -> WordRows(t, subgroup, settings?.hidden.orEmpty(), onOpen = { details = it to null }) else -> WordRows(t, subgroup, settings?.hidden.orEmpty(), onOpen = { openLesson(it, null) })
}
}
details?.let { (lesson, date) ->
ModalBottomSheet(onDismissRequest = { details = null }) {
LessonDetails(lesson, timetable?.bells.orEmpty(), date, vm, onClose = { details = null })
} }
} }
} }
@@ -233,6 +233,17 @@ private fun Planner(
} }
} }
// A finger swipe to another day ticks like a tap on the strip.
val haptic = LocalHapticFeedback.current
val draggingDays by pager.interactionSource.collectIsDraggedAsState()
var daySwiped by remember { mutableStateOf(false) }
LaunchedEffect(draggingDays) { if (draggingDays) daySwiped = true }
LaunchedEffect(pager) {
snapshotFlow { pager.settledPage }.drop(1).collect {
if (daySwiped) haptic.performHapticFeedback(HapticFeedbackType.SegmentTick)
daySwiped = false
}
}
// The strip is a pager of weeks: it follows the day pager, and swiping it moves the days. // The strip is a pager of weeks: it follows the day pager, and swiping it moves the days.
val strip = rememberPagerState(initialPage = mondays.indexOf(date.monday()).coerceAtLeast(0)) { mondays.size } val strip = rememberPagerState(initialPage = mondays.indexOf(date.monday()).coerceAtLeast(0)) { mondays.size }
LaunchedEffect(date.monday()) { LaunchedEffect(date.monday()) {
@@ -336,6 +347,7 @@ private fun Planner(
enabled = { d -> dates.contains(d) }, enabled = { d -> dates.contains(d) },
onSelect = ::go, onSelect = ::go,
onWeek = ::week, onWeek = ::week,
marks = true,
modifier = Modifier.widthIn(max = MAX_CONTENT).padding(horizontal = 8.dp), modifier = Modifier.widthIn(max = MAX_CONTENT).padding(horizontal = 8.dp),
) )
} }
@@ -382,7 +394,8 @@ private fun Planner(
modifier = if (wide) Modifier.padding(top = 16.dp) else Modifier, modifier = if (wide) Modifier.padding(top = 16.dp) else Modifier,
) )
} }
} else PullToRefreshBox(isRefreshing = status.loading, onRefresh = { vm.refresh() }) { } else ExpressiveRefresh(refreshing = status.loading, onRefresh = { vm.refresh() }) {
val fresh by vm.fresh.collectAsStateWithLifecycle()
HorizontalPager(state = pager, modifier = Modifier.fillMaxSize()) { page -> HorizontalPager(state = pager, modifier = Modifier.fillMaxSize()) { page ->
val pageDate = dates[page] val pageDate = dates[page]
DayPage( DayPage(
@@ -390,6 +403,8 @@ private fun Planner(
slots = vm.slotsOn(t, pageDate, subgroup), slots = vm.slotsOn(t, pageDate, subgroup),
places = vm.placesOn(t, pageDate, subgroup), places = vm.placesOn(t, pageDate, subgroup),
onOpen = onOpen, onOpen = onOpen,
fresh = fresh,
onFreshShown = vm::freshShown,
) )
} }
} }
@@ -437,12 +452,30 @@ private fun Planner(
} }
@Composable @Composable
private fun DayPage(date: LocalDate, slots: List<Slot>, places: List<DayPlace>, onOpen: (Lesson, LocalDate) -> Unit) { private fun DayPage(
date: LocalDate,
slots: List<Slot>,
places: List<DayPlace>,
onOpen: (Lesson, LocalDate) -> Unit,
fresh: Set<String> = emptySet(),
onFreshShown: (String) -> Unit = {},
) {
val clock = rememberClock() val clock = rememberClock()
val isToday = date == today() val isToday = date == today()
val next = if (isToday) slots.firstOrNull { it.start.isAfter(clock) } else null val next = if (isToday) slots.firstOrNull { it.start.isAfter(clock) } else null
val list = rememberLazyListState()
// Today opens on the lesson under way (or the next one), not on the morning's first: one
// look tells where to go now. Once per page, so a scroll back up stays where it is.
LaunchedEffect(isToday) {
if (!isToday) return@LaunchedEffect
val now = slots.indexOfFirst { clock.isBefore(it.end) }
if (now <= 0) return@LaunchedEffect
val before = (if (places.isNotEmpty()) 1 else 0) + slots.take(now).sumOf { it.lessons.size }
list.scrollToItem(before)
}
ContentColumn { ContentColumn {
LazyColumn( LazyColumn(
state = list,
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 24.dp + navBarPadding()), contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 24.dp + navBarPadding()),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@@ -472,7 +505,11 @@ private fun DayPage(date: LocalDate, slots: List<Slot>, places: List<DayPlace>,
date = date, date = date,
status = status, status = status,
running = running, running = running,
progress = if (running) Duration.between(slot.start, clock).toSeconds().toFloat() /
Duration.between(slot.start, slot.end).toSeconds().coerceAtLeast(1) else null,
onClick = { onOpen(lesson, date) }, onClick = { onOpen(lesson, date) },
fresh = lesson.key in fresh,
onFreshShown = { onFreshShown(lesson.key) },
) )
} }
} }
@@ -491,7 +528,12 @@ fun LessonCard(
date: LocalDate? = null, date: LocalDate? = null,
status: String? = null, status: String? = null,
running: Boolean = false, running: Boolean = false,
/** How far the lesson under way has gone, 0..1: a line runs round the card. */
progress: Float? = null,
onClick: (() -> Unit)?, 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 color = subjectColor(lesson.subject)
val show = LocalDisplay.current val show = LocalDisplay.current
@@ -509,7 +551,7 @@ fun LessonCard(
color = if (running) color.onSoft else color.onSoft.copy(alpha = 0.12f), color = if (running) color.onSoft else color.onSoft.copy(alpha = 0.12f),
contentColor = if (running) color.soft else color.onSoft, contentColor = if (running) color.soft else color.onSoft,
) { ) {
Text(it, style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp)) RollingText(it, style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp))
} }
} }
} }
@@ -527,6 +569,13 @@ fun LessonCard(
lesson.type.ifEmpty { null }?.takeIf { session == null }, lesson.type.ifEmpty { null }?.takeIf { session == null },
if (lesson.variant != "all") (if (lesson.variant == "upper") "белые недели" else "зелёные недели") else null, if (lesson.variant != "all") (if (lesson.variant == "upper") "белые недели" else "зелёные недели") else null,
if (lesson.groups.size > 1) "общее занятие" 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) } val mark = date?.let { LocalMarks.current.forLesson(lesson.subject, it, lesson.start) }
if (tags.isNotEmpty() || lesson.uncertain || mark != null) FlowRow( if (tags.isNotEmpty() || lesson.uncertain || mark != null) FlowRow(
@@ -544,7 +593,26 @@ fun LessonCard(
if (date != null) NotesHint(LocalNotes.current.forLesson(lesson.subject, date, lesson.start), color.onSoft) 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 { val border = when {
running && progress != null -> null
running -> BorderStroke(2.dp, color.onSoft) running -> BorderStroke(2.dp, color.onSoft)
session != null -> BorderStroke(2.dp, session.outline()) session != null -> BorderStroke(2.dp, session.outline())
else -> null else -> null
@@ -552,10 +620,10 @@ fun LessonCard(
if (onClick != null) Surface( if (onClick != null) Surface(
onClick = onClick, onClick = onClick,
shape = MaterialTheme.shapes.large, shape = MaterialTheme.shapes.large,
color = color.soft, color = fill,
contentColor = color.onSoft, contentColor = color.onSoft,
border = border, border = border,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth().then(origin).then(live),
content = content, content = content,
) else Surface( ) else Surface(
shape = MaterialTheme.shapes.large, shape = MaterialTheme.shapes.large,
@@ -627,6 +695,9 @@ internal fun LessonDetails(
onClose: () -> Unit = {}, onClose: () -> Unit = {},
) { ) {
val bell = bells.firstOrNull { it.start == lesson.start } 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( Column(
Modifier Modifier
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
@@ -668,7 +739,11 @@ internal fun LessonDetails(
Text(lesson.raw, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(16.dp).fillMaxWidth()) Text(lesson.raw, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(16.dp).fillMaxWidth())
} }
if (vm != null) { if (vm != null) {
OutlinedButton(onClick = { vm.setHidden(lesson.hideKey, true); onClose() }) { 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)) Icon(Icons.Rounded.VisibilityOff, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Text("Скрыть такие занятия") Text("Скрыть такие занятия")
@@ -681,6 +756,7 @@ internal fun LessonDetails(
) )
} }
} }
}
} }
@Composable @Composable
@@ -710,7 +786,7 @@ private fun WordRows(t: Timetable, subgroup: String?, hidden: Set<String>, onOpe
Heading(dayName(day.day), style = MaterialTheme.typography.headlineSmallEmphasized) Heading(dayName(day.day), style = MaterialTheme.typography.headlineSmallEmphasized)
} }
val lessons = day.lessons.filter { subgroup == null || it.subgroup == null || it.subgroup == subgroup } val lessons = day.lessons.filter { subgroup == null || it.subgroup == null || it.subgroup == subgroup }
.filter { it.hideKey !in hidden } .filter { !it.hiddenIn(hidden) }
val places = if (lessons.isEmpty()) emptyList() else day.places.forSubgroup(subgroup) val places = if (lessons.isEmpty()) emptyList() else day.places.forSubgroup(subgroup)
if (places.isNotEmpty()) item(key = "p${day.day}") { DayPlaces(places) } if (places.isNotEmpty()) item(key = "p${day.day}") { DayPlaces(places) }
items(lessons, key = { "${day.day}-${it.key}" }) { lesson -> items(lessons, key = { "${day.day}-${it.key}" }) { lesson ->
@@ -828,3 +904,31 @@ private val FACULTIES = mapOf(
) )
fun facultyShort(name: String): String? = name.takeIf { it.isNotEmpty() }?.let { FACULTIES[it] ?: it } 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

@@ -51,7 +51,6 @@ 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.LoadingIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -243,7 +242,7 @@ fun SearchScreen(vm: MainViewModel, nav: AppNav) {
) { ) {
if (target != null) { if (target != null) {
val d = day val d = day
if (loading && d == null) item { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { LoadingIndicator() } } if (loading && d == null) item { LessonsSkeleton(3) }
if (d != null) { if (d != null) {
if (d.lessons.isEmpty()) item { if (d.lessons.isEmpty()) item {
Box(Modifier.fillParentMaxHeight(0.8f).fillMaxWidth(), contentAlignment = Alignment.Center) { Box(Modifier.fillParentMaxHeight(0.8f).fillMaxWidth(), contentAlignment = Alignment.Center) {

View File

@@ -2,6 +2,8 @@
package org.xtimms.psu.schedule.ui package org.xtimms.psu.schedule.ui
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.selection.toggleable
import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.Role
@@ -43,6 +45,8 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.xtimms.psu.schedule.BuildConfig import org.xtimms.psu.schedule.BuildConfig
import org.xtimms.psu.schedule.data.ThemeMode import org.xtimms.psu.schedule.data.ThemeMode
import org.xtimms.psu.schedule.data.OPTION_PREFIX
import org.xtimms.psu.schedule.data.AUDIENCE_PREFIX
import org.xtimms.psu.schedule.notify.Reminders import org.xtimms.psu.schedule.notify.Reminders
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -120,9 +124,12 @@ fun SettingsScreen(vm: MainViewModel, onBack: () -> Unit) {
SwitchRow("Пометки из Word", "Например, «3-13н.» — даты по ним уже рассчитаны", d.notes) { SwitchRow("Пометки из Word", "Например, «3-13н.» — даты по ним уже рассчитаны", d.notes) {
vm.setDisplay(d.copy(notes = it)) vm.setDisplay(d.copy(notes = it))
} }
if (s.hidden.isNotEmpty()) { // The options of a choice the student did not take, and the lessons for foreign
// students or the others, are set in "Группы", not here.
val hidden = s.hidden.filterNot { it.startsWith(OPTION_PREFIX) || it.startsWith(AUDIENCE_PREFIX) }
if (hidden.isNotEmpty()) {
Text("Скрытые занятия", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant) Text("Скрытые занятия", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
s.hidden.sorted().forEach { key -> hidden.sorted().forEach { key ->
val (subject, type) = key.split("|", limit = 2).let { it[0] to it.getOrElse(1) { "" } } val (subject, type) = key.split("|", limit = 2).let { it[0] to it.getOrElse(1) { "" } }
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Text( Text(
@@ -283,7 +290,12 @@ private fun Section(title: String, content: @Composable () -> Unit) {
} }
@Composable @Composable
private fun SwitchRow(title: String, text: String, checked: Boolean, onChange: (Boolean) -> Unit) { private fun SwitchRow(title: String, text: String, checked: Boolean, change: (Boolean) -> Unit) {
val haptic = LocalHapticFeedback.current
val onChange: (Boolean) -> Unit = { on ->
haptic.performHapticFeedback(if (on) HapticFeedbackType.ToggleOn else HapticFeedbackType.ToggleOff)
change(on)
}
// The whole row is the touch target, not only the switch. // The whole row is the touch target, not only the switch.
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,

View File

@@ -0,0 +1,195 @@
package org.xtimms.psu.schedule.ui
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import org.xtimms.psu.schedule.ui.theme.card
// 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.
/**
* A soft light sweeping across a placeholder, once every 1.2 s. With animations turned off in
* the system it stays a plain tone.
*/
@Composable
private fun Modifier.shimmer(): Modifier {
val ink = MaterialTheme.colorScheme.onSurface
val base = ink.copy(alpha = 0.08f)
val light = ink.copy(alpha = 0.15f)
if (rememberReducedMotion()) return background(base)
val sweep = rememberInfiniteTransition(label = "shimmer")
val x by sweep.animateFloat(-1f, 2f, infiniteRepeatable(tween(1200, easing = LinearEasing)), label = "x")
return drawBehind {
val w = size.width.coerceAtLeast(1f)
drawRect(Brush.linearGradient(listOf(base, light, base), start = Offset(w * (x - 0.5f), 0f), end = Offset(w * (x + 0.5f), size.height)))
}
}
/** One placeholder bar or shape. */
@Composable
private fun Bone(modifier: Modifier, shape: Shape = MaterialTheme.shapes.small) {
Box(modifier.clip(shape).shimmer())
}
/** Screen readers hear "Загрузка" once, not every bar. */
private fun Modifier.loading(): Modifier = clearAndSetSemantics {
contentDescription = "Загрузка"
liveRegion = LiveRegionMode.Polite
}
/** A lesson card: subject, time, room and teacher. */
@Composable
fun LessonCardSkeleton(modifier: Modifier = Modifier) {
Surface(shape = MaterialTheme.shapes.large, color = MaterialTheme.colorScheme.surfaceContainerHigh, modifier = modifier.fillMaxWidth()) {
Column(Modifier.padding(horizontal = 20.dp, vertical = 16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Bone(Modifier.fillMaxWidth(0.72f).height(20.dp))
Bone(Modifier.fillMaxWidth(0.4f).height(16.dp))
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
Bone(Modifier.width(72.dp).height(14.dp))
Bone(Modifier.width(112.dp).height(14.dp))
}
}
}
}
/** A few lesson cards, for a day that is loading. */
@Composable
fun LessonsSkeleton(count: Int = 3, modifier: Modifier = Modifier) {
Column(modifier.loading(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
repeat(count) { LessonCardSkeleton() }
}
}
/** The schedule: the week's day strip, then the day's cards. */
@Composable
fun ScheduleSkeleton() {
Column(Modifier.fillMaxSize().loading()) {
Row(Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 8.dp)) {
repeat(7) {
Column(Modifier.weight(1f), horizontalAlignment = Alignment.CenterHorizontally) {
Bone(Modifier.width(18.dp).height(12.dp))
Spacer(Modifier.height(8.dp))
Bone(Modifier.size(36.dp), CircleShape)
}
}
}
ContentColumn {
LessonsSkeleton(4, Modifier.padding(16.dp))
}
}
}
/** The overview: the week card, "Сегодня" and the next lessons. */
@Composable
fun OverviewSkeleton() {
ContentColumn {
Column(
Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 8.dp).loading(),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
SkeletonCard {
Bone(Modifier.width(120.dp).height(20.dp))
Bone(Modifier.fillMaxWidth(0.7f).height(26.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
repeat(6) { Bone(Modifier.weight(1f).height(52.dp), MaterialTheme.shapes.medium) }
}
}
Bone(Modifier.width(140.dp).height(28.dp).padding(top = 4.dp))
SkeletonCard {
Bone(Modifier.width(180.dp).height(20.dp))
repeat(3) {
Row(verticalAlignment = Alignment.CenterVertically) {
Bone(Modifier.size(12.dp), CircleShape)
Spacer(Modifier.width(16.dp))
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Bone(Modifier.widthIn(max = 260.dp).fillMaxWidth(0.8f).height(18.dp))
Bone(Modifier.width(120.dp).height(14.dp))
}
}
}
}
}
}
}
/** Subjects: a card per subject with its badge, name and next lesson. */
@Composable
fun SubjectsSkeleton() {
ContentColumn {
Column(
Modifier.fillMaxSize().padding(16.dp).loading(),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
repeat(5) {
SkeletonCard {
Row(verticalAlignment = Alignment.CenterVertically) {
Bone(Modifier.size(48.dp), CircleShape)
Spacer(Modifier.width(16.dp))
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Bone(Modifier.fillMaxWidth(0.75f).height(18.dp))
Bone(Modifier.fillMaxWidth(0.5f).height(14.dp))
}
}
}
}
}
}
}
/** Rows of a list with an avatar: the groups of the catalog. */
@Composable
fun RowsSkeleton(count: Int = 6, avatar: Dp = 40.dp) {
Column(Modifier.fillMaxWidth().loading(), verticalArrangement = Arrangement.spacedBy(18.dp)) {
repeat(count) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(horizontal = 4.dp)) {
Bone(Modifier.size(avatar), CircleShape)
Spacer(Modifier.width(16.dp))
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Bone(Modifier.width(120.dp).height(16.dp))
Bone(Modifier.width(180.dp).height(13.dp))
}
}
}
}
}
@Composable
private fun SkeletonCard(content: @Composable () -> Unit) {
Surface(shape = MaterialTheme.shapes.extraLarge, color = MaterialTheme.colorScheme.card, modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { content() }
}
}

View File

@@ -41,7 +41,6 @@ import androidx.compose.material.icons.rounded.Person
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.LoadingIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
@@ -128,7 +127,7 @@ fun SubjectsScreen(vm: MainViewModel, nav: AppNav) {
) )
val t = timetable val t = timetable
when { when {
t == null && status.loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { LoadingIndicator() } t == null && status.loading -> SubjectsSkeleton()
t == null -> CenteredEmpty { t == null -> CenteredEmpty {
Empty("Предметов пока нет", "Выберите группу — предметы появятся из её расписания.", icon = Icons.AutoMirrored.Rounded.MenuBook, action = "Выбрать группу" to nav.openGroups) Empty("Предметов пока нет", "Выберите группу — предметы появятся из её расписания.", icon = Icons.AutoMirrored.Rounded.MenuBook, action = "Выбрать группу" to nav.openGroups)
} }
@@ -328,6 +327,8 @@ private fun Fact(icon: ImageVector, text: String, strong: Boolean = false) {
@Composable @Composable
private fun SubjectDetails(s: SubjectSummary, vm: MainViewModel) { private fun SubjectDetails(s: SubjectSummary, vm: MainViewModel) {
val color = subjectColor(s.name) val color = subjectColor(s.name)
// A task deleted in the sheet offers "Вернуть" in the sheet itself.
UndoArea(bottom = false) {
Column( Column(
Modifier Modifier
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
@@ -372,6 +373,7 @@ private fun SubjectDetails(s: SubjectSummary, vm: MainViewModel) {
} }
} }
} }
}
} }
@Composable @Composable
@@ -389,6 +391,7 @@ private fun DetailLine(icon: ImageVector, label: String, value: String) {
/** Every task and note of the subject, open ones first, with a quick add for the next lesson. */ /** Every task and note of the subject, open ones first, with a quick add for the next lesson. */
@Composable @Composable
private fun SubjectNotes(s: SubjectSummary, vm: MainViewModel) { private fun SubjectNotes(s: SubjectSummary, vm: MainViewModel) {
val undo = LocalUndo.current
val all by vm.notes.collectAsStateWithLifecycle() val all by vm.notes.collectAsStateWithLifecycle()
val group = vm.currentGroupKey() ?: return val group = vm.currentGroupKey() ?: return
val notes = all.filter { subjectKey(it.subject) == subjectKey(s.name) } val notes = all.filter { subjectKey(it.subject) == subjectKey(s.name) }
@@ -414,7 +417,7 @@ private fun SubjectNotes(s: SubjectSummary, vm: MainViewModel) {
editing = editing, editing = editing,
groupKey = editing?.group ?: group, groupKey = editing?.group ?: group,
onSave = { vm.saveNote(it); adding = false; editing = null }, onSave = { vm.saveNote(it); adding = false; editing = null },
onDelete = { vm.deleteNote(it.id); editing = null }, onDelete = { vm.deleteNote(it.id, undo); editing = null },
onDismiss = { adding = false; editing = null }, onDismiss = { adding = false; editing = null },
) )
} }

View File

@@ -131,7 +131,7 @@ private fun StandLesson(slot: Slot, clock: LocalTime, isToday: Boolean, onOpen:
shape = MaterialTheme.shapes.extraLarge, shape = MaterialTheme.shapes.extraLarge,
color = color.soft, color = color.soft,
contentColor = color.onSoft, contentColor = color.onSoft,
modifier = modifier, 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.padding(24.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
@@ -147,7 +147,7 @@ private fun StandLesson(slot: Slot, clock: LocalTime, isToday: Boolean, onOpen:
color = if (running) color.container else MaterialTheme.colorScheme.surface, color = if (running) color.container else MaterialTheme.colorScheme.surface,
contentColor = if (running) color.content else MaterialTheme.colorScheme.onSurface, contentColor = if (running) color.content else MaterialTheme.colorScheme.onSurface,
) { ) {
Text( RollingText(
if (running) "Идёт · ещё ${minutesLabel(minutes)}" else "Через ${minutesLabel(minutes)}", if (running) "Идёт · ещё ${minutesLabel(minutes)}" else "Через ${minutesLabel(minutes)}",
style = MaterialTheme.typography.labelLarge, style = MaterialTheme.typography.labelLarge,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),

View File

@@ -0,0 +1,67 @@
package org.xtimms.psu.schedule.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
/**
* "Задание удалено · Вернуть": actions that are easy to regret are done at once and offered back
* in a snackbar instead of asking first. [offer] shows it in the nearest host — the screen's, or
* a bottom sheet's own, since a sheet covers the screen's.
*/
class UndoHost(val state: SnackbarHostState, private val scope: CoroutineScope) {
/** [onGone] runs when the chance to undo has passed: the snackbar timed out or was replaced. */
fun offer(message: String, onUndo: () -> Unit, onGone: () -> Unit = {}) {
scope.launch {
// A new action replaces the previous offer, which then cannot be undone any more.
state.currentSnackbarData?.dismiss()
var undone = false
try {
undone = state.showSnackbar(message, actionLabel = "Вернуть", duration = SnackbarDuration.Long) ==
SnackbarResult.ActionPerformed
if (undone) onUndo()
} finally {
if (!undone) onGone()
}
}
}
}
val LocalUndo = staticCompositionLocalOf<UndoHost?> { null }
/** A host for [content] and its snackbar at the bottom: the app's screens, or a sheet. */
@Composable
fun UndoArea(modifier: Modifier = Modifier, bottom: Boolean = true, content: @Composable BoxScope.() -> Unit) {
val state = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
val host = remember(state, scope) { UndoHost(state, scope) }
Box(modifier) {
CompositionLocalProvider(LocalUndo provides host) { content() }
SnackbarHost(
state,
Modifier
.align(Alignment.BottomCenter)
.then(if (bottom) Modifier.navigationBarsPadding() else Modifier)
.padding(horizontal = 16.dp, vertical = 12.dp)
.widthIn(max = 560.dp)
.fillMaxWidth(),
)
}
}

View File

@@ -2,6 +2,8 @@
package org.xtimms.psu.schedule.ui package org.xtimms.psu.schedule.ui
import androidx.compose.runtime.getValue
import androidx.compose.animation.core.tween
import androidx.compose.ui.text.intl.LocaleList import androidx.compose.ui.text.intl.LocaleList
import androidx.compose.ui.text.style.Hyphens import androidx.compose.ui.text.style.Hyphens
import androidx.compose.ui.text.style.LineBreak import androidx.compose.ui.text.style.LineBreak
@@ -165,7 +167,8 @@ fun WeekGrid(
} }
} }
if (d == today() && clock.hour in first until last) { if (d == today() && clock.hour in first until last) {
val y = top(clock) // The "now" line slides down with the clock instead of hopping every half minute.
val y by androidx.compose.animation.core.animateDpAsState(top(clock), tween(1000), label = "now")
Box(Modifier.offset(y = y - 1.dp).fillMaxWidth().height(2.dp).background(MaterialTheme.colorScheme.primary)) Box(Modifier.offset(y = y - 1.dp).fillMaxWidth().height(2.dp).background(MaterialTheme.colorScheme.primary))
Box(Modifier.offset(x = (-5).dp, y = y - 5.dp).size(10.dp).background(MaterialTheme.colorScheme.primary, CircleShape)) Box(Modifier.offset(x = (-5).dp, y = y - 5.dp).size(10.dp).background(MaterialTheme.colorScheme.primary, CircleShape))
} }
@@ -179,6 +182,7 @@ 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 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 {
@@ -192,7 +196,7 @@ private fun LessonBlock(lesson: Lesson, slot: Slot, wide: Boolean, notes: Int, o
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.semantics { modifier = modifier.then(origin).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 "")

View File

@@ -0,0 +1,246 @@
package org.xtimms.psu.schedule.widget
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.glance.ColorFilter
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
import androidx.glance.Image
import androidx.glance.ImageProvider
import androidx.glance.LocalSize
import androidx.glance.appwidget.action.actionStartActivity
import androidx.glance.action.clickable
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver
import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.provideContent
import androidx.glance.layout.Alignment
import androidx.glance.layout.Column
import androidx.glance.layout.Row
import androidx.glance.layout.Spacer
import androidx.glance.layout.fillMaxHeight
import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.height
import androidx.glance.layout.padding
import androidx.glance.layout.size
import androidx.glance.layout.width
import androidx.glance.text.FontWeight
import androidx.glance.text.Text
import androidx.glance.text.TextAlign
import androidx.glance.text.TextStyle
import org.xtimms.psu.schedule.Launch
import org.xtimms.psu.schedule.R
import org.xtimms.psu.schedule.app
import org.xtimms.psu.schedule.data.Week
import org.xtimms.psu.schedule.ui.theme.WeekColors
import androidx.glance.color.ColorProvider
import org.xtimms.psu.schedule.data.longLabel
import org.xtimms.psu.schedule.data.monday
import org.xtimms.psu.schedule.data.plural
import org.xtimms.psu.schedule.data.shortWeekday
import org.xtimms.psu.schedule.data.today
import org.xtimms.psu.schedule.ui.abbreviate
import java.time.LocalDate
/** A day of the week as the widget shows it. */
private data class WeekDay(
val date: LocalDate,
/** Abbreviations of the day's lessons, one per start time: "ФГА", "ЯПОА / ПУПР". */
val lessons: List<String>,
/** "Полоцк" when the day is in another town. */
val place: String?,
val moved: Boolean,
)
private data class WeekState(
val group: String?,
val week: Week?,
val monday: LocalDate,
val days: List<WeekDay>,
val dynamic: Boolean,
)
private suspend fun state(context: Context): WeekState? {
val app = context.app
val t = app.repository.timetable.value ?: return null
val s = app.settings.now()
// On Sunday the coming week is the useful one, as in the overview's week pane.
val monday = (if (today().dayOfWeek.value == 7) today().plusDays(1) else today()).monday()
val all = (0..6L).map { monday.plusDays(it) }.map { d ->
val lessons = app.repository.lessonsOn(t, d, s.subgroup, s.hidden)
WeekDay(
date = d,
lessons = lessons.groupBy { it.start }.toSortedMap().values.map { slot -> slot.joinToString(" / ") { abbreviate(it.subject) } },
place = app.repository.placesOn(t, d, s.subgroup, s.hidden).firstOrNull()?.place,
moved = lessons.any { it.roomMoved },
)
}
return WeekState(
group = t.group,
week = t.days.firstOrNull { it.date == monday.toString() }?.week,
monday = monday,
// Sunday only takes a column when something happens on it.
days = all.filter { it.date.dayOfWeek.value < 7 || it.lessons.isNotEmpty() },
dynamic = s.dynamicColor,
)
}
/**
* The week at a glance (4×2 and larger): each day with its number of lessons, a city mark on
* days in another town and a swap mark on days with a room move; taller, the lessons too. A day
* opens in the app.
*/
class WeekWidget : GlanceAppWidget() {
override val sizeMode = SizeMode.Exact
override suspend fun provideGlance(context: Context, id: GlanceId) {
val data = state(context)
val dynamic = data?.dynamic ?: context.app.settings.now().dynamicColor
provideContent { WidgetTheme(dynamic) { Content(context, data) } }
}
@Composable
private fun Content(context: Context, data: WeekState?) {
Column(
modifier = GlanceModifier
.fillMaxSize()
.roundedBackground(GlanceTheme.colors.widgetBackground, 28.dp)
.padding(12.dp),
) {
if (data == null) {
Text(
"Откройте приложение и выберите группу",
style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 15.sp),
modifier = GlanceModifier.padding(4.dp).clickable(actionStartActivity(Launch.intent(context, "today"))),
)
return@Column
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = GlanceModifier.fillMaxWidth().padding(start = 4.dp, end = 4.dp, bottom = 8.dp)
.clickable(actionStartActivity(Launch.intent(context, "week"))),
) {
Text(
data.week?.let { "Неделя ${it.n}" } ?: "Неделя",
style = TextStyle(color = GlanceTheme.colors.primary, fontWeight = FontWeight.Bold, fontSize = 15.sp),
)
data.week?.let { w ->
Spacer(GlanceModifier.width(8.dp))
// The university's week colours, as on the app's week mark.
Text(
if (w.white) "белая" else "зелёная",
style = TextStyle(
color = ColorProvider(day = WeekColors.content(w.white, false), night = WeekColors.content(w.white, true)),
fontSize = 11.sp,
fontWeight = FontWeight.Medium,
),
modifier = GlanceModifier
.roundedBackground(
if (w.white) GlanceTheme.colors.surfaceVariant
else ColorProvider(day = WeekColors.container(false, false), night = WeekColors.container(false, true)),
8.dp,
)
.padding(horizontal = 6.dp, vertical = 1.dp),
)
}
Spacer(GlanceModifier.defaultWeight())
Text(data.group.orEmpty(), maxLines = 1, style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp))
}
// Header about 40dp, padding 24dp; a lesson line about 14dp below the day's head.
val height = LocalSize.current.height
val lines = ((height - 40.dp - 24.dp - 62.dp) / 14.dp).toInt().coerceIn(0, 6)
val summary = summary(data.days)
val roomForSummary = summary != null && height >= 150.dp
// Glance keeps at most ten children in a row: the gaps are the tiles' padding, not spacers.
Row(modifier = GlanceModifier.fillMaxWidth().defaultWeight()) {
data.days.forEachIndexed { index, day ->
DayTile(
context, day, lines - if (roomForSummary) 1 else 0,
GlanceModifier.defaultWeight().fillMaxHeight()
.padding(start = if (index > 0) 2.dp else 0.dp, end = if (index < data.days.lastIndex) 2.dp else 0.dp),
)
}
}
if (roomForSummary) Text(
summary!!,
maxLines = 1,
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 11.sp),
modifier = GlanceModifier.padding(start = 4.dp, top = 6.dp),
)
}
}
@Composable
private fun DayTile(context: Context, day: WeekDay, lines: Int, modifier: GlanceModifier) {
val isToday = day.date == today()
val past = day.date < today()
val count = day.lessons.size
val (fill, ink) = when {
isToday -> GlanceTheme.colors.primary to GlanceTheme.colors.onPrimary
count > 0 -> GlanceTheme.colors.secondaryContainer to GlanceTheme.colors.onSecondaryContainer
else -> GlanceTheme.colors.surfaceVariant to GlanceTheme.colors.onSurfaceVariant
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
.roundedBackground(fill, 16.dp)
.padding(vertical = 6.dp, horizontal = 2.dp)
.clickable(actionStartActivity(Launch.intent(context, "day", day.date))),
) {
Text(day.date.shortWeekday(), style = TextStyle(color = ink, fontSize = 11.sp, textAlign = TextAlign.Center))
Text(
"${day.date.dayOfMonth}",
style = TextStyle(color = ink, fontSize = 17.sp, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center),
)
Text(
if (count == 0) "—" else "$count ${plural(count, "пара", "пары", "пар")}",
maxLines = 1,
style = TextStyle(
color = ink,
fontSize = 10.sp,
textAlign = TextAlign.Center,
fontWeight = if (past) FontWeight.Normal else FontWeight.Medium,
),
)
if (day.place != null || day.moved) Row(modifier = GlanceModifier.padding(top = 2.dp)) {
if (day.place != null) Image(
ImageProvider(R.drawable.ic_widget_city),
contentDescription = "Занятия: ${day.place}",
colorFilter = ColorFilter.tint(if (isToday) ink else GlanceTheme.colors.tertiary),
modifier = GlanceModifier.size(14.dp),
)
if (day.moved) Image(
ImageProvider(R.drawable.ic_widget_move),
contentDescription = "Перенос аудитории",
colorFilter = ColorFilter.tint(if (isToday) ink else GlanceTheme.colors.error),
modifier = GlanceModifier.size(14.dp).padding(start = if (day.place != null) 2.dp else 0.dp),
)
}
// Taller: the lessons themselves, as the week grid abbreviates them.
if (lines > 0) {
Spacer(GlanceModifier.height(2.dp))
day.lessons.take(lines).forEach {
Text(it, maxLines = 1, style = TextStyle(color = ink, fontSize = 10.sp, textAlign = TextAlign.Center))
}
if (day.lessons.size > lines) Text("…", style = TextStyle(color = ink, fontSize = 10.sp))
}
}
}
/** "Полоцк: вт, пт · перенос: пн, чт", or null for an ordinary week. */
private fun summary(days: List<WeekDay>): String? {
val away = days.filter { it.place != null }.groupBy { it.place!! }
.map { (place, list) -> "$place: " + list.joinToString(", ") { it.date.shortWeekday().lowercase() } }
val moved = days.filter { it.moved }.takeIf { it.isNotEmpty() }
?.let { list -> "перенос: " + list.joinToString(", ") { it.date.shortWeekday().lowercase() } }
return (away + listOfNotNull(moved)).joinToString(" · ").ifEmpty { null }
}
}
class WeekWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = WeekWidget()
}

View File

@@ -17,6 +17,7 @@ import org.xtimms.psu.schedule.notify.DayAlerts
import org.xtimms.psu.schedule.notify.LiveLesson import org.xtimms.psu.schedule.notify.LiveLesson
import org.xtimms.psu.schedule.notify.Reminders import org.xtimms.psu.schedule.notify.Reminders
import org.xtimms.psu.schedule.widget.TodayWidget import org.xtimms.psu.schedule.widget.TodayWidget
import org.xtimms.psu.schedule.widget.WeekWidget
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
/** /**
@@ -61,6 +62,7 @@ class SyncWorker(context: Context, params: WorkerParameters) : CoroutineWorker(c
LiveLesson.reschedule(context, t, s, app.repository) LiveLesson.reschedule(context, t, s, app.repository)
TodayWidget().updateAll(context) TodayWidget().updateAll(context)
NextLessonWidget().updateAll(context) NextLessonWidget().updateAll(context)
WeekWidget().updateAll(context)
LessonTileService.refresh(context) LessonTileService.refresh(context)
} }
} }
@@ -71,6 +73,7 @@ class WidgetTick(context: Context, params: WorkerParameters) : CoroutineWorker(c
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
TodayWidget().updateAll(applicationContext) TodayWidget().updateAll(applicationContext)
NextLessonWidget().updateAll(applicationContext) NextLessonWidget().updateAll(applicationContext)
WeekWidget().updateAll(applicationContext)
LessonTileService.refresh(applicationContext) LessonTileService.refresh(applicationContext)
return Result.success() return Result.success()
} }

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The calendar grows into place. -->
<set xmlns:android="http://schemas.android.com/apk/res/android">
<objectAnimator
android:duration="450"
android:interpolator="@interpolator/emphasized_decelerate"
android:propertyName="scaleX"
android:valueFrom="0.7"
android:valueTo="1" />
<objectAnimator
android:duration="450"
android:interpolator="@interpolator/emphasized_decelerate"
android:propertyName="scaleY"
android:valueFrom="0.7"
android:valueTo="1" />
</set>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Last, the green check is drawn: the day is sorted. -->
<objectAnimator xmlns:android="http://schemas.android.com/apk/res/android"
android:duration="350"
android:startOffset="400"
android:interpolator="@interpolator/emphasized_decelerate"
android:propertyName="trimPathEnd"
android:valueFrom="0"
android:valueTo="1" />

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- A line of the calendar appears. -->
<objectAnimator xmlns:android="http://schemas.android.com/apk/res/android"
android:duration="250"
android:interpolator="@android:interpolator/linear"
android:propertyName="strokeAlpha"
android:valueFrom="0"
android:valueTo="1" />

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The rings fade in as they drop. -->
<objectAnimator xmlns:android="http://schemas.android.com/apk/res/android"
android:duration="250"
android:startOffset="150"
android:interpolator="@android:interpolator/linear"
android:propertyName="strokeAlpha"
android:valueFrom="0"
android:valueTo="1" />

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The binder rings drop onto the calendar. -->
<objectAnimator xmlns:android="http://schemas.android.com/apk/res/android"
android:duration="350"
android:startOffset="150"
android:interpolator="@interpolator/emphasized_decelerate"
android:propertyName="translateY"
android:valueFrom="-4"
android:valueTo="0" />

View File

@@ -0,0 +1,63 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The first frame of the launch animation (drawable/splash_icon_animated.xml).
The app icon for the launch screen: a blue disc, the calendar, the green check (ios/Config/AppIcon.svg). -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M54,18a36,36 0,1 1,0 72a36,36 0,1 1,0 -72z">
<aapt:attr name="android:fillColor">
<gradient
android:type="linear"
android:startX="54"
android:startY="18"
android:endX="54"
android:endY="90">
<item android:offset="0" android:color="#FF1A7FD1" />
<item android:offset="1" android:color="#FF004F87" />
</gradient>
</aapt:attr>
</path>
<group
android:name="calendar"
android:pivotX="54"
android:pivotY="54"
android:scaleX="0.7"
android:scaleY="0.7">
<path
android:name="frame"
android:pathData="M35.76,40.08H72.24V72.08H35.76Z"
android:strokeColor="#FFFFFFFF"
android:strokeWidth="3.84"
android:strokeLineJoin="round"
android:strokeAlpha="0" />
<path
android:name="header"
android:pathData="M35.76,48.72H72.24"
android:strokeColor="#FFFFFFFF"
android:strokeWidth="3.84"
android:strokeLineCap="round"
android:strokeAlpha="0" />
<group
android:name="tabs"
android:translateY="-4">
<path
android:name="rings"
android:pathData="M45.68,35.92V43.6M62.32,35.92V43.6"
android:strokeColor="#FFFFFFFF"
android:strokeWidth="3.84"
android:strokeLineCap="round"
android:strokeAlpha="0" />
</group>
<path
android:name="check"
android:pathData="M45.84,59.92L51.44,65.52L62.48,54.16"
android:strokeColor="#FFC8EFB8"
android:strokeWidth="4"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:trimPathEnd="0" />
</group>
</vector>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Week widget: a day in another town; tinted by the widget. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M15,11V5l-3,-3 -3,3v2H3v14h18V11h-6zM7,19H5v-2h2v2zM7,15H5v-2h2v2zM7,11H5V9h2v2zM13,19h-2v-2h2v2zM13,15h-2v-2h2v2zM13,11h-2V9h2v2zM13,7h-2V5h2v2zM19,19h-2v-2h2v2zM19,15h-2v-2h2v2z" />
</vector>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Week widget: a room move; tinted by the widget. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M6.99,11L3,15l3.99,4v-3H14v-2H6.99v-3zM21,9l-3.99,-4v3H10v2h7.01v3L21,9z" />
</vector>

View File

@@ -0,0 +1,63 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Before Android 12 the launch icon does not move: its last frame. The animation starts from drawable-v31.
The app icon for the launch screen: a blue disc, the calendar, the green check (ios/Config/AppIcon.svg). -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M54,18a36,36 0,1 1,0 72a36,36 0,1 1,0 -72z">
<aapt:attr name="android:fillColor">
<gradient
android:type="linear"
android:startX="54"
android:startY="18"
android:endX="54"
android:endY="90">
<item android:offset="0" android:color="#FF1A7FD1" />
<item android:offset="1" android:color="#FF004F87" />
</gradient>
</aapt:attr>
</path>
<group
android:name="calendar"
android:pivotX="54"
android:pivotY="54"
android:scaleX="1"
android:scaleY="1">
<path
android:name="frame"
android:pathData="M35.76,40.08H72.24V72.08H35.76Z"
android:strokeColor="#FFFFFFFF"
android:strokeWidth="3.84"
android:strokeLineJoin="round"
android:strokeAlpha="1" />
<path
android:name="header"
android:pathData="M35.76,48.72H72.24"
android:strokeColor="#FFFFFFFF"
android:strokeWidth="3.84"
android:strokeLineCap="round"
android:strokeAlpha="1" />
<group
android:name="tabs"
android:translateY="0">
<path
android:name="rings"
android:pathData="M45.68,35.92V43.6M62.32,35.92V43.6"
android:strokeColor="#FFFFFFFF"
android:strokeWidth="3.84"
android:strokeLineCap="round"
android:strokeAlpha="1" />
</group>
<path
android:name="check"
android:pathData="M45.84,59.92L51.44,65.52L62.48,54.16"
android:strokeColor="#FFC8EFB8"
android:strokeWidth="4"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:trimPathEnd="1" />
</group>
</vector>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The launch screen's icon, about 750 ms: the calendar grows, its rings drop, the check is drawn. -->
<animated-vector xmlns:android="http://schemas.android.com/apk/res/android"
android:drawable="@drawable/splash_icon">
<target android:name="calendar" android:animation="@animator/splash_calendar" />
<target android:name="frame" android:animation="@animator/splash_fade" />
<target android:name="header" android:animation="@animator/splash_fade" />
<target android:name="tabs" android:animation="@animator/splash_tabs" />
<target android:name="rings" android:animation="@animator/splash_rings" />
<target android:name="check" android:animation="@animator/splash_check" />
</animated-vector>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- M3 emphasized decelerate: things entering the screen. -->
<pathInterpolator xmlns:android="http://schemas.android.com/apk/res/android"
android:controlX1="0.05"
android:controlY1="0.7"
android:controlX2="0.1"
android:controlY2="1" />

View File

@@ -5,4 +5,12 @@
<item name="android:statusBarColor">@android:color/transparent</item> <item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item> <item name="android:navigationBarColor">@android:color/transparent</item>
</style> </style>
<!-- The launch screen (core-splashscreen): the app surface and the animated icon, then the app. -->
<style name="Theme.PsuSchedule.Starting" parent="Theme.SplashScreen">
<item name="windowSplashScreenBackground">@color/surface</item>
<item name="windowSplashScreenAnimatedIcon">@drawable/splash_icon_animated</item>
<item name="windowSplashScreenAnimationDuration">750</item>
<item name="postSplashScreenTheme">@style/Theme.PsuSchedule</item>
</style>
</resources> </resources>

View File

@@ -17,5 +17,7 @@
<string name="shortcut_task_long">Новое задание</string> <string name="shortcut_task_long">Новое задание</string>
<string name="next_widget_name">Следующая пара</string> <string name="next_widget_name">Следующая пара</string>
<string name="next_widget_description">Когда следующая пара, что это и в какой аудитории</string> <string name="next_widget_description">Когда следующая пара, что это и в какой аудитории</string>
<string name="week_widget_name">Неделя</string>
<string name="week_widget_description">Пары каждого дня недели, дни в другом городе и переносы аудиторий</string>
<string name="tile_name">Пара</string> <string name="tile_name">Пара</string>
</resources> </resources>

View File

@@ -6,4 +6,12 @@
<item name="android:statusBarColor">@android:color/transparent</item> <item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item> <item name="android:navigationBarColor">@android:color/transparent</item>
</style> </style>
<!-- The launch screen (core-splashscreen): the app surface and the animated icon, then the app. -->
<style name="Theme.PsuSchedule.Starting" parent="Theme.SplashScreen">
<item name="windowSplashScreenBackground">@color/surface</item>
<item name="windowSplashScreenAnimatedIcon">@drawable/splash_icon_animated</item>
<item name="windowSplashScreenAnimationDuration">750</item>
<item name="postSplashScreenTheme">@style/Theme.PsuSchedule</item>
</style>
</resources> </resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Where the camera app may write a photo for a task: the cache, copied and shrunk afterwards. -->
<paths>
<cache-path name="camera" path="camera/" />
</paths>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/week_widget_description"
android:initialLayout="@layout/glance_default_loading_layout"
android:minWidth="250dp"
android:minHeight="110dp"
android:targetCellWidth="4"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen" />

View File

@@ -5,6 +5,7 @@ composeBom = "2026.09.01"
activity = "1.13.0" activity = "1.13.0"
lifecycle = "2.11.0" lifecycle = "2.11.0"
coreKtx = "1.19.1" coreKtx = "1.19.1"
splashscreen = "1.2.0"
coroutines = "1.11.0" coroutines = "1.11.0"
serialization = "1.11.0" serialization = "1.11.0"
okhttp = "4.12.0" okhttp = "4.12.0"
@@ -27,6 +28,8 @@ activity-compose = { module = "androidx.activity:activity-compose", version.ref
lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" } lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" } lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" }
core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" } core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" }
# The launch screen with the animated icon on every Android version (Android 12+ draws its own).
splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "splashscreen" }
coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" } serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" }
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }

View File

@@ -15,7 +15,11 @@ export const lessonLine = (l) =>
// The town only when the cell names one or the day's note puts the lesson there. // The town only when the cell names one or the day's note puts the lesson there.
[l.room, l.place].filter(Boolean).join(", "), [l.room, l.place].filter(Boolean).join(", "),
l.teacher, l.teacher,
[...l.groups, ...l.subgroups.map((s) => `${s.group}/${s.label.replace("Подгруппа ", "п")}`)].join(","), [...l.groups, ...l.subgroups.map((s) => `${s.group}/${s.label.replace("Подгруппа ", "п")}`)].join(",") +
// "Немецкий язык" or "Французский язык": a part of the group takes one of them.
(l.option ? " · на выбор" : "") +
// Russian for foreign students while the others have physical education.
(l.audience === "foreign" ? " · иностранцам" : l.audience === "local" ? " · кроме иностранцев" : ""),
] ]
.map((part) => String(part ?? "").replace(/\s+/g, " ").trim()) .map((part) => String(part ?? "").replace(/\s+/g, " ").trim())
.join(" | "); .join(" | ");

View File

@@ -80,7 +80,9 @@ app.use("/api", (_req, res, next) => {
// 12 — a JSON log line per request; // 12 — a JSON log line per request;
// 13 — the dashboard at /status (password), /api/health only says alive, ready and versions; // 13 — the dashboard at /status (password), /api/health only says alive, ready and versions;
// 14 — a lesson's `place` and a day's `places` ("Занятия в Полоцке") in /api/timetable. // 14 — a lesson's `place` and a day's `places` ("Занятия в Полоцке") in /api/timetable.
const API_VERSION = 14; // 15 — a lesson's `option` and the group's `choices` (German or French for parts of a group),
// a lesson's `audience` (foreign students or the others) and the group's `audience` flag.
const API_VERSION = 15;
/** /**
* What the server still waits for before it answers from memory: the catalog, the week table * What the server still waits for before it answers from memory: the catalog, the week table

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 = 15; export const PARSER_VERSION = 17;
const days = [ const days = [
"понедельник", "понедельник",
"вторник", "вторник",
@@ -206,17 +206,89 @@ function byKind(text) {
return [`${weeks(m[1])} ${at(m[2])}`, `${weeks(m[3])} ${at(m[4])}`]; return [`${weeks(m[1])} ${at(m[2])}`, `${weeks(m[3])} ${at(m[4])}`];
} }
// Who a lesson is for: "(для иностранных студентов)", "Русский язык как иностранный" are for
// foreign students only, "(кроме иностр.студ.)" for the others.
const FOR_FOREIGN = /\(\s*для\s+иностр[^)]*\)/iu;
const NOT_FOREIGN = /\(\s*кроме\s+иностр[^)]*\)/iu;
const FOREIGN_SUBJECT = /как\s+иностранн/iu;
export function audienceOf(text) {
if (NOT_FOREIGN.test(text)) return "local";
if (FOR_FOREIGN.test(text) || FOREIGN_SUBJECT.test(text)) return "foreign";
return null;
}
/**
* One cell, two lessons at once: Russian for foreign students while the others have another
* lesson. "Ф и з и ч е с к а я к у л ь т у р а\nРусский язык как иностранный (для иностранных
* студентов) Семченко Е.Д. ауд.313н" or "Русский язык как иностранный (п.з.) Немирская В.А. 331Е
* ФИЗИЧЕСКАЯ КУЛЬТУРА". A line with only the teacher below the lesson ("Хаткевич Т.В. ауд. 323н")
* is no lesson of its own.
*/
// "Новополоцк" or "17.00" before a lesson is its town or time, not another lesson.
const aLesson = (text) =>
/(?:[А-ЯЁа-яё]\s?){3}/u.test(lessonPlace(text).rest.replace(/\d{1,2}[.:]\d{2}|(?:г\.\s*)?(?:ново)?полоцк[а-яё]*/giu, ""));
export function byAudience(text) {
const lines = text.split("\n");
const at = lines.findIndex(
(line, i) => i > 0 && audienceOf(line) === "foreign" && !/^\s*[А-ЯЁ][а-яё-]+\s+[А-ЯЁ]\.\s*[А-ЯЁ]\./u.test(line),
);
const head = lines.slice(0, at).join("\n");
if (at > 0 && audienceOf(head) !== "foreign" && aLesson(head))
return [
{ text: lines.slice(0, at).join("\n").trim(), audience: "local" },
{ text: lines.slice(at).join("\n").trim(), audience: "foreign" },
];
// "… Немирская В.А. 331Е со 2 нед. ФИЗИЧЕСКАЯ КУЛЬТУРА": the others' lesson in capitals after it.
const tail = text.match(
/^([\s\S]*?(?:\d{2,3}\s*[А-ЯЁа-яё]?|[А-ЯЁ]\.))\s+((?:(?:с|со)\s*)?(?:\d{1,2}(?:\s*[-–,]\s*\d{1,2})*\s*н(?:ед)?\.?\s+)?[А-ЯЁ]{3,}(?:\s+[А-ЯЁ]{3,})+)\s*$/u,
);
if (tail && audienceOf(tail[1]) === "foreign" && !audienceOf(tail[2]))
return [
{ text: tail[1].trim(), audience: "foreign" },
{ text: tail[2].trim(), audience: "local" },
];
// "Ф и з и ч е с к а я к у л ь т у р а 24-КФ: Русский язык как иностранный …" on one line.
const inline = text.match(/^((?:[А-ЯЁа-яё]\s?){3}[^\n]*?)\s+((?:\d{2}-[^\s:]+:\s*)?Русский\s+язык\s+как\s+иностранн[\s\S]*)$/u);
if (inline && !audienceOf(inline[1]) && aLesson(inline[1]))
return [
{ text: inline[1].trim(), audience: "local" },
{ text: inline[2].trim(), audience: "foreign" },
];
return null;
}
// "25-ДОи", "24-ТНГи-2": a group of foreign students.
const FOREIGN_GROUP = /[А-ЯЁа-яё]и(?:-\d+)?$/u;
// A line that is a whole lesson: a subject, its kind in brackets and a teacher.
const WHOLE_LESSON = /^\s*[А-ЯЁ][^\n]*?\((?:л|п\.?\s*з|л\.?\s*з|лаб|пр|сем)\.?\)[^\n]*?[А-ЯЁ][а-яё-]+\s+[А-ЯЁ]\.\s*[А-ЯЁ]\./u;
/**
* "Немецкий язык (п.з) – Найверт Ю.О. 208А,\nФранцузский язык (п.з.) – Глобенко М.В. 336Е":
* one cell over the whole group, two lessons at once for the parts of the group that study one
* or the other. A line without a subject of its own ("321Д") stays with the lesson above.
*/
export function parallel(text) {
const parts = [];
for (const line of text.split("\n")) {
if (!parts.length || WHOLE_LESSON.test(line)) parts.push(line);
else parts[parts.length - 1] += "\n" + line;
}
if (parts.length < 2 || !parts.every((p) => WHOLE_LESSON.test(p))) return [text];
return parts.map((p) => p.trim().replace(/[,;]+$/, ""));
}
/** /**
* A cell's entries, each `{text, main, plan}`: the whole text, its first line (subject, kind, * A cell's entries, each `{text, main, plan}`: the whole text, its first line (subject, kind,
* teacher and room are read from it) and the plan. A line that names only rooms or teachers by week * teacher and room are read from it) and the plan. A line that names only rooms or teachers by week
* ("1-9н. 502н", "с 10н. – 359", "2-9н.-Клос О.Ю., 10-14н.-Конторов В.Э.") belongs to the lesson * ("1-9н. 502н", "с 10н. – 359", "2-9н.-Клос О.Ю., 10-14н.-Конторов В.Э.") belongs to the lesson
* above: its plan of rooms and teachers by week. A line with weeks and a kind but no subject * above: its plan of rooms and teachers by week. A line with weeks and a kind but no subject
* ("7,9,11н. – (п.з.) Савицкая К.Д. ауд. А1-06 8,12,14н. – (п.з.) …") is the subject above in * ("7,9,11н. – (п.з.) Савицкая К.Д. ауд. А1-06 8,12,14н. – (п.з.) …") is the subject above in
* other weeks. A stray "." is no lesson. * other weeks. A stray "." is no lesson. Lessons side by side for parts of the group ([parallel])
* also carry `choice`: the subjects to choose from.
*/ */
export function entries(raw) { export function entries(raw) {
const out = []; const out = [];
for (const alternative of alternatives(raw).flatMap(byKind)) { for (const part of alternatives(raw).flatMap(byKind)) {
// "…Третьяк З.И. 331Е,\n2-13н. …": the comma before the next lesson is not part of the room.
const alternative = part.replace(/[,;]\s*$/, "");
if (!/[\p{L}\d]/u.test(alternative)) continue; if (!/[\p{L}\d]/u.test(alternative)) continue;
const prev = out.at(-1); const prev = out.at(-1);
if (prev && onlyPlace(alternative)) { if (prev && onlyPlace(alternative)) {
@@ -238,6 +310,24 @@ export function entries(raw) {
} }
continue; continue;
} }
const options = parallel(alternative);
// "Теория языка (кроме иностр.студ.) … / История языка (для иностр.студ.) …": not a choice,
// each lesson is for its own students.
const marks = options.map(audienceOf);
if (options.length > 1 && marks.every(Boolean)) {
options.forEach((o, i) => out.push({ text: o, main: o, plan: [], audience: marks[i] }));
continue;
}
const split = byAudience(alternative);
if (split) {
for (const p of split) out.push({ text: p.text, main: p.text, plan: weekPlan(p.text).filter((w) => w.color), audience: p.audience });
continue;
}
if (options.length > 1) {
const choice = options.map((o) => details(o).subject);
for (const o of options) out.push({ text: o, main: o, plan: [], choice });
continue;
}
// "бел.н.504н/зел.н.524н": a room for white weeks and one for green ones. // "бел.н.504н/зел.н.524н": a room for white weeks and one for green ones.
out.push({ text: alternative, main: alternative, plan: weekPlan(alternative).filter((p) => p.color) }); out.push({ text: alternative, main: alternative, plan: weekPlan(alternative).filter((p) => p.color) });
} }
@@ -400,6 +490,8 @@ export function parseDocx(buffer, source = {}) {
const lessons = [], const lessons = [],
// "Занятия в Полоцке": the days a group, or one of its subgroups, spends in another town. // "Занятия в Полоцке": the days a group, or one of its subgroups, spends in another town.
places = [], places = [],
// Subjects that parts of a group choose between: the second foreign language, an elective.
choices = [],
groups = new Set(), groups = new Set(),
warnings = new Set(); warnings = new Set();
// The catalog folder ("2026–2027") wins: some files keep last year's header text. // The catalog folder ("2026–2027") wins: some files keep last year's header text.
@@ -576,11 +668,23 @@ export function parseDocx(buffer, source = {}) {
raw = note.rest; raw = note.rest;
if (!raw.trim()) return; if (!raw.trim()) return;
} }
for (const { text: whole, main: alternative, plan } of entries(raw)) { for (const { text: whole, main, plan, choice, audience } of entries(raw)) {
if (choice) choices.push({ groups: targetGroups, options: choice });
// "24-КФ: Русский язык как иностранный": one of the cell's groups.
let alternative = main;
let lessonGroups = targetGroups;
const named = main.match(/^\s*([^:\n]{4,24}):\s+(?=\S)/u);
const only = named ? groupsIn(named[1]) : [];
if (only.length && only.every((g) => targetGroups.includes(g))) {
lessonGroups = only;
alternative = main.slice(named[0].length);
}
// "Единый день информирования 10.09;15.10;19.11;17.12": the dates are not part of the // "Единый день информирования 10.09;15.10;19.11;17.12": the dates are not part of the
// subject, nor is a move to another room on a date ("412н 26.10.2026-333"). // subject, nor is a move to another room on a date ("412н 26.10.2026-333").
const read = (text) => { const read = (text) => {
const data = details(roomMoves(text).rest.replace(DATE_LIST, "").replace(/[ \t]{2,}/g, " ").trim()); // "(для иностранных студентов)" is who the lesson is for, no part of its name or room.
const plain = text.replace(FOR_FOREIGN, " ").replace(NOT_FOREIGN, " ");
const data = details(roomMoves(plain).rest.replace(DATE_LIST, "").replace(/[ \t]{2,}/g, " ").trim());
if (credits && data.type === "Занятие") data.type = "Зачёт"; if (credits && data.type === "Занятие") data.type = "Зачёт";
return data; return data;
}; };
@@ -618,7 +722,7 @@ export function parseDocx(buffer, source = {}) {
...(start && { start, end: lead.end }), ...(start && { start, end: lead.end }),
...data, ...data,
...overrides, ...overrides,
groups: targetGroups, groups: lessonGroups,
subgroups, subgroups,
variant: spansBoth ? "all" : variant, variant: spansBoth ? "all" : variant,
weekRanges: weekRanges:
@@ -633,6 +737,10 @@ export function parseDocx(buffer, source = {}) {
? cell.color ? cell.color
: null, : null,
raw: whole, raw: whole,
...(choice && { option: data.subject }),
// Groups of foreign students alone ("25-ДОи") have Russian as everyone's lesson.
...((audience || audienceOf(whole)) &&
!lessonGroups.every(FOREIGN_GROUP.test.bind(FOREIGN_GROUP)) && { audience: audience || audienceOf(whole) }),
// Rooms and teachers by week, or by the week's colour. // Rooms and teachers by week, or by the week's colour.
...(plan.length && { plan }), ...(plan.length && { plan }),
}); });
@@ -696,6 +804,19 @@ export function parseDocx(buffer, source = {}) {
const labels = new Map(); const labels = new Map();
for (const l of lessons) for (const l of lessons)
for (const s of l.subgroups) labels.set(s.group, new Set([...(labels.get(s.group) || []), s.label])); for (const s of l.subgroups) labels.set(s.group, new Set([...(labels.get(s.group) || []), s.label]));
// The chosen subject on its own in a cell ("Немецкий язык" on Thursday) is the same choice:
// only that part of the group has it.
const norm = (s) => s.toLowerCase().replace(/\s+/g, " ").trim();
const sets = [];
for (const c of choices) {
const key = c.groups.join("|") + "#" + c.options.map(norm).sort().join("|");
if (!sets.some((s) => s.key === key)) sets.push({ key, groups: c.groups, options: c.options });
}
for (const lesson of lessons) {
if (lesson.option || lesson.subgroups.length) continue;
const set = sets.find((s) => s.groups.some((g) => lesson.groups.includes(g)) && s.options.some((o) => norm(o) === norm(lesson.subject)));
if (set) lesson.option = set.options.find((o) => norm(o) === norm(lesson.subject));
}
for (const lesson of lessons) { for (const lesson of lessons) {
if (lesson.place || !places.length) continue; if (lesson.place || !places.length) continue;
const place = placeOf(lesson, places, (group) => [...(labels.get(group) || [])]); const place = placeOf(lesson, places, (group) => [...(labels.get(group) || [])]);
@@ -708,6 +829,7 @@ export function parseDocx(buffer, source = {}) {
(a, b) => a.day - b.day || a.start.localeCompare(b.start), (a, b) => a.day - b.day || a.start.localeCompare(b.start),
), ),
...(places.length && { places }), ...(places.length && { places }),
...(sets.length && { choices: sets.map(({ groups, options }) => ({ groups, options })) }),
notes: document.notes, notes: document.notes,
warnings: [...warnings], warnings: [...warnings],
source: { source: {

View File

@@ -31,6 +31,10 @@ function lessonOut(lesson, group, people, date, status, calendar, week = null) {
condition: lesson.condition, condition: lesson.condition,
variant: lesson.variant, variant: lesson.variant,
subgroup: lesson.subgroups.find((s) => s.group === group)?.label || null, subgroup: lesson.subgroups.find((s) => s.group === group)?.label || null,
// "Немецкий язык" when part of the group studies German and part French (see `choices`).
option: lesson.option || null,
// "foreign": for foreign students only (Russian as a foreign language); "local": for the others.
audience: lesson.audience || null,
groups: lesson.groups, groups: lesson.groups,
uncertain: status === "maybe", uncertain: status === "maybe",
raw: lesson.raw, raw: lesson.raw,
@@ -159,6 +163,10 @@ export function timetable({ file, schedule, calendar, group, from, days, range,
...extra.map((e) => e.lesson.subgroups?.find((s) => s.group === group)?.label).filter(Boolean), ...extra.map((e) => e.lesson.subgroups?.find((s) => s.group === group)?.label).filter(Boolean),
]), ]),
].sort((a, b) => a.localeCompare(b, "ru", { numeric: true })), ].sort((a, b) => a.localeCompare(b, "ru", { numeric: true })),
// What parts of the group choose between: [["Немецкий язык", "Французский язык"]].
choices: (schedule.choices || []).filter((c) => c.groups.includes(group)).map((c) => c.options),
// Some lessons are for foreign students only, or for the others: worth asking which one you are.
audience: lessons.some((l) => l.audience),
bells: calendar?.bells || [], bells: calendar?.bells || [],
from, from,
days: out, days: out,

View File

@@ -1,6 +1,6 @@
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 } from "../server/parser.js"; import { groupsIn, alternatives, entries, details, leadingTime, parallel, audienceOf } from "../server/parser.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";
@@ -118,6 +118,40 @@ test("Подгруппа в тексте ячейки, точка вместо
assert.equal(details("Интеллектуальная собственность - драйвер инновационного развития\nна пространстве Евразии").subject.includes("Евразии"), true); assert.equal(details("Интеллектуальная собственность - драйвер инновационного развития\nна пространстве Евразии").subject.includes("Евразии"), true);
}); });
test("Немецкий или французский: два занятия в одной ячейке на всю группу", () => {
const cell = "Немецкий язык (п.з) – Найверт Ю.О. 208А,\nФранцузский язык (п.з.) – Глобенко М.В. 336Е";
const list = entries(cell);
assert.deepEqual(list.map((e) => details(e.main).room), ["208А", "336Е"]);
assert.deepEqual(list.map((e) => details(e.main).teacher), ["Найверт Ю.О.", "Глобенко М.В."]);
assert.deepEqual(list[0].choice, ["Немецкий язык", "Французский язык"]);
// A room on its own line stays with its lesson.
assert.deepEqual(
parallel("Теория языка (англ.) (л.) (кроме иностр.студ.)-Чеботарева Е.М.305Г\nИстория языка (англ.) (л) (для иностр.студ.) Третьяк З.И.\n321Д").at(-1),
"История языка (англ.) (л) (для иностр.студ.) Третьяк З.И.\n321Д",
);
// A lesson with its teacher on the next line is one lesson.
assert.equal(entries("Русский язык как иностранный (п.з.)\nНемирская В.А. 305Г").length, 1);
// The comma before the next week's lesson is no part of the room.
assert.equal(details(entries("2-13н. Белорусский язык (п.з.) - Третьяк З.И. 331Е,\n2-13н. Стилистика (п.з.) – Коротких А.В. 301Г")[0].main).room, "331Е");
});
test("Пары для иностранных студентов и для остальных", () => {
assert.equal(audienceOf("История языка (п.з.) (кроме иностр.студ.) Ковшик О.С. 305Г"), "local");
assert.equal(audienceOf("Русский язык как иностранный (п.з.) Немирская В.А. 305Г"), "foreign");
assert.equal(audienceOf("Современный разговорный иностранный язык (на базе второго иностранного языка)"), null);
// Russian for foreign students while the others have physical education, in every spelling.
const pe = (text) => entries(text).map((e) => [details(e.main).subject, e.audience]);
assert.deepEqual(pe("Ф и з и ч е с к а я к у л ь т у р а\nРусский язык как иностранный (для иностранных студентов) Семченко Е.Д. ауд.313н"), [
["Ф и з и ч е с к а я к у л ь т у р а", "local"],
["Русский язык как иностранный (для иностранных студентов)", "foreign"],
]);
assert.deepEqual(pe("со 2 нед. Русский язык как иностранный (п.з.) Немирская В.А. 331Е со 2 нед. ФИЗИЧЕСКАЯ КУЛЬТУРА").map((p) => p[1]), ["foreign", "local"]);
assert.equal(details(entries("Русский язык как иностранный (п.з.) Немирская В.А. 331Е ФИЗИЧЕСКАЯ КУЛЬТУРА")[0].main).room, "331Е");
// The town or the teacher on a line of its own is no lesson for the others.
assert.equal(entries("17.00 Новополоцк\nРусский язык как иностранный (п.з.)\nКириллова В.С. ауд. 537н").length, 1);
assert.equal(entries("Русский язык как иностранный (для иностранных студентов)\nХаткевич Т.В. ауд. 323н").length, 1);
});
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

@@ -17,7 +17,7 @@
вт 15:20–16:45 | каждую неделю, нижняя строка | Физический практикум: физика атома | Занятие | 210н | Окунев Д.О. | 24-КФ,24-КФ/п2 вт 15:20–16:45 | каждую неделю, нижняя строка | Физический практикум: физика атома | Занятие | 210н | Окунев Д.О. | 24-КФ,24-КФ/п2
вт 15:20–16:45 | нед. 2-14, верхняя строка | Криптографические методы защиты информации | Лабораторная | 421н | Кухта С.В. | 24-КБ,24-КБ/п2 вт 15:20–16:45 | нед. 2-14, верхняя строка | Криптографические методы защиты информации | Лабораторная | 421н | Кухта С.В. | 24-КБ,24-КБ/п2
вт 17:00–18:25 | нед. 1-13, верхняя строка | Криптографические методы защиты информации | Лабораторная | 421н | Кухта С.В. | 24-КБ,24-КБ/п1 вт 17:00–18:25 | нед. 1-13, верхняя строка | Криптографические методы защиты информации | Лабораторная | 421н | Кухта С.В. | 24-КБ,24-КБ/п1
пн 08:30–09:55 | каждую неделю | Русский язык как иностранный (для иностранных студентов) | Занятие | 412н | Козыренко А.Н. | 24-КБ пн 08:30–09:55 | каждую неделю | Русский язык как иностранный | Занятие | 412н | Козыренко А.Н. | 24-КБ · иностранцам
пн 10:05–11:30 | каждую неделю | Уравнения математической физики | Лекция | 336н | Козлов А.А. | 24-КБ пн 10:05–11:30 | каждую неделю | Уравнения математической физики | Лекция | 336н | Козлов А.А. | 24-КБ
пн 11:45–13:10 | каждую неделю | Операционные системы | Лекция | 343 | Бураченок А.Л. | 24-КБ пн 11:45–13:10 | каждую неделю | Операционные системы | Лекция | 343 | Бураченок А.Л. | 24-КБ
пн 11:45–13:10 | каждую неделю | Электродинамика | Лекция | 402н | Вабищевич С.А. | 24-КФ пн 11:45–13:10 | каждую неделю | Электродинамика | Лекция | 402н | Вабищевич С.А. | 24-КФ
@@ -60,7 +60,8 @@
сб 11:45–13:10 | каждую неделю, верхняя строка | Компьютерные сети | Лабораторная | 215н | Филиппов Ю.И. | 24-КБ,24-КБ/п1 сб 11:45–13:10 | каждую неделю, верхняя строка | Компьютерные сети | Лабораторная | 215н | Филиппов Ю.И. | 24-КБ,24-КБ/п1
сб 11:45–13:10 | каждую неделю, нижняя строка | Компьютерные сети | Лабораторная | 215н | Филиппов Ю.И. | 24-КБ,24-КБ/п2 сб 11:45–13:10 | каждую неделю, нижняя строка | Компьютерные сети | Лабораторная | 215н | Филиппов Ю.И. | 24-КБ,24-КБ/п2
ср 10:05–11:30 | нед. 2-14 | Модуль «Майнор» | Занятие | | | 24-КБ,24-КФ ср 10:05–11:30 | нед. 2-14 | Модуль «Майнор» | Занятие | | | 24-КБ,24-КФ
чт 08:30–09:55 | каждую неделю | Ф и з и ч е с к а я к у л ь т у р а 24-КФ: Русский язык как иностранный (для иностранных студентов) | Занятие | 537н | Кириллова В.С. | 24-КБ,24-КФ чт 08:30–09:55 | каждую неделю | Русский язык как иностранный | Занятие | 537н | Кириллова В.С. | 24-КФ · иностранцам
чт 08:30–09:55 | каждую неделю | Ф и з и ч е с к а я к у л ь т у р а | Занятие | | | 24-КБ,24-КФ · кроме иностранцев
чт 10:05–11:30 | каждую неделю, верхняя строка | Криптографические методы защиты информации | Лекция | 329н | Кухта С.В. | 24-КБ чт 10:05–11:30 | каждую неделю, верхняя строка | Криптографические методы защиты информации | Лекция | 329н | Кухта С.В. | 24-КБ
чт 10:05–11:30 | каждую неделю, верхняя строка | Основы математического моделирования | Лабораторная | 215н | Сковородко М.А. | 24-КФ,24-КФ/п1 чт 10:05–11:30 | каждую неделю, верхняя строка | Основы математического моделирования | Лабораторная | 215н | Сковородко М.А. | 24-КФ,24-КФ/п1
чт 10:05–11:30 | каждую неделю, верхняя строка | Основы радиоэлектроники | Лабораторная | 406н | Грозберг Ю.Г. | 24-КФ,24-КФ/п2 чт 10:05–11:30 | каждую неделю, верхняя строка | Основы радиоэлектроники | Лабораторная | 406н | Грозберг Ю.Г. | 24-КФ,24-КФ/п2

View File

@@ -1,5 +1,6 @@
# Осенний семестр · 25-РГФ-1, 25-РГФ-2 # Осенний семестр · 25-РГФ-1, 25-РГФ-2
вт 08:30–09:55 | каждую неделю | Немецкий язык | Практика | 336Е | Найверт Ю.О. | 25-РГФ-1 вт 08:30–09:55 | каждую неделю | Немецкий язык | Практика | 319Д | Найверт Ю.О. | 25-РГФ-1 · на выбор
вт 08:30–09:55 | каждую неделю | Французский язык | Практика | 336Е | Глобенко М.В. | 25-РГФ-1 · на выбор
вт 10:05–11:30 | нед. 2-14 | Философия | Практика | 206Г | Андрейчик Е.В. | 25-РГФ-1,25-РГФ-2 вт 10:05–11:30 | нед. 2-14 | Философия | Практика | 206Г | Андрейчик Е.В. | 25-РГФ-1,25-РГФ-2
вт 11:45–13:10 | каждую неделю | Английский язык | Практика | 133Е | Лагун А.М. | 25-РГФ-2 вт 11:45–13:10 | каждую неделю | Английский язык | Практика | 133Е | Лагун А.М. | 25-РГФ-2
вт 11:45–13:10 | нед. 1-13 | История литературы страны изучаемого языка XVII века | Лекция | 305Г | Смулькевич А.А. | 25-РГФ-1 вт 11:45–13:10 | нед. 1-13 | История литературы страны изучаемого языка XVII века | Лекция | 305Г | Смулькевич А.А. | 25-РГФ-1
@@ -10,18 +11,20 @@
вт 15:20–16:45 | каждую неделю, нижняя строка | Современный разговорный иностранный язык (англ.) | Практика | 203Г | Ковшик О.С. | 25-РГФ-1,25-РГФ-1/п1 вт 15:20–16:45 | каждую неделю, нижняя строка | Современный разговорный иностранный язык (англ.) | Практика | 203Г | Ковшик О.С. | 25-РГФ-1,25-РГФ-1/п1
вт 15:20–16:45 | каждую неделю, нижняя строка | Современный разговорный иностранный язык (англ.) | Практика | 304Г | Федорович Н.Я. | 25-РГФ-1,25-РГФ-1/п2 вт 15:20–16:45 | каждую неделю, нижняя строка | Современный разговорный иностранный язык (англ.) | Практика | 304Г | Федорович Н.Я. | 25-РГФ-1,25-РГФ-1/п2
вт 15:20–16:45 | нед. с 3, верхняя строка | Политология | Практика | 206Г | Радюк А.Л. | 25-РГФ-1,25-РГФ-2 вт 15:20–16:45 | нед. с 3, верхняя строка | Политология | Практика | 206Г | Радюк А.Л. | 25-РГФ-1,25-РГФ-2
пн 08:30–09:55 | каждую неделю | Русский язык как иностранный | Практика | 305Г | Немирская В.А. | 25-РГФ-1,25-РГФ-1/п1 пн 08:30–09:55 | каждую неделю | Русский язык как иностранный | Практика | 305Г | Немирская В.А. | 25-РГФ-1,25-РГФ-1/п1 · иностранцам
пн 10:05–11:30 | каждую неделю | Немецкий язык | Практика | 336Е | Найверт Ю.О. | 25-РГФ-1 пн 10:05–11:30 | каждую неделю | Немецкий язык | Практика | 208А | Найверт Ю.О. | 25-РГФ-1 · на выбор
пн 10:05–11:30 | каждую неделю | Основы перевода | Практика | 331Е | Семченок Л.И. | 25-РГФ-2 пн 10:05–11:30 | каждую неделю | Основы перевода | Практика | 331Е | Семченок Л.И. | 25-РГФ-2
пн 10:05–11:30 | каждую неделю | Французский язык | Практика | 336Е | Глобенко М.В. | 25-РГФ-1 · на выбор
пн 11:45–13:10 | каждую неделю | ФИЗИЧЕСКАЯ КУЛЬТУРА | Занятие | | | 25-РГФ-1,25-РГФ-2 пн 11:45–13:10 | каждую неделю | ФИЗИЧЕСКАЯ КУЛЬТУРА | Занятие | | | 25-РГФ-1,25-РГФ-2
пн 13:40–15:05 | каждую неделю | Практика устной и письменной речи (англ.) | Практика | 331Е | Федорович Н.Я. | 25-РГФ-1,25-РГФ-1/п2 пн 13:40–15:05 | каждую неделю | Практика устной и письменной речи (англ.) | Практика | 331Е | Федорович Н.Я. | 25-РГФ-1,25-РГФ-1/п2
пн 13:40–15:05 | каждую неделю | Практическая фонетика (англ) | Практика | 319Д | Кунчевская А.В. | 25-РГФ-1,25-РГФ-1/п1 пн 13:40–15:05 | каждую неделю | Практическая фонетика (англ) | Практика | 319Д | Кунчевская А.В. | 25-РГФ-1,25-РГФ-1/п1
пн 13:40–15:05 | каждую неделю | Практическая фонетика (нем) | Практика | 330Е | Логвинова И.В. | 25-РГФ-2 пн 13:40–15:05 | каждую неделю | Практическая фонетика (нем) | Практика | 330Е | Логвинова И.В. | 25-РГФ-2
пн 15:20–16:45 | каждую неделю | Английский язык | Практика | 248Ж | Лагун А.М. | 25-РГФ-2 пн 15:20–16:45 | каждую неделю | Английский язык | Практика | 248Ж | Лагун А.М. | 25-РГФ-2
пн 15:20–16:45 | каждую неделю | Немецкий язык | Практика | 336Е | Найверт Ю.О. | 25-РГФ-1 пн 15:20–16:45 | каждую неделю | Немецкий язык | Практика | 332Е | Найверт Ю.О. | 25-РГФ-1 · на выбор
пн 15:20–16:45 | каждую неделю | Французский язык | Практика | 336Е | Глобенко М.В. | 25-РГФ-1 · на выбор
пт 08:30–09:55 | каждую неделю | Основы перевода | Практика | 208А | Семченок Л.И. | 25-РГФ-2 пт 08:30–09:55 | каждую неделю | Основы перевода | Практика | 208А | Семченок Л.И. | 25-РГФ-2
пт 08:30–09:55 | каждую неделю, верхняя строка | Русский язык как иностранный | Практика | 307Г | Немирская В.А. | 25-РГФ-1,25-РГФ-1/п1 пт 08:30–09:55 | каждую неделю, верхняя строка | Русский язык как иностранный | Практика | 307Г | Немирская В.А. | 25-РГФ-1,25-РГФ-1/п1 · иностранцам
пт 08:30–09:55 | каждую неделю, нижняя строка | Французский язык | Практика | 336Е | Глобенко М.В. | 25-РГФ-1 пт 08:30–09:55 | каждую неделю, нижняя строка | Французский язык | Практика | 336Е | Глобенко М.В. | 25-РГФ-1 · на выбор
пт 10:05–11:30 | каждую неделю | История зарубежной литературы Средних веков и Возрождения | Практика | 305Г | Лушневская Е.В. | 25-РГФ-1,25-РГФ-2 пт 10:05–11:30 | каждую неделю | История зарубежной литературы Средних веков и Возрождения | Практика | 305Г | Лушневская Е.В. | 25-РГФ-1,25-РГФ-2
пт 11:45–13:10 | каждую неделю | Основы перевода | Практика | 203Г | Смулькевич А.А. | 25-РГФ-1 пт 11:45–13:10 | каждую неделю | Основы перевода | Практика | 203Г | Смулькевич А.А. | 25-РГФ-1
пт 11:45–13:10 | нед. 1-13 | История литературы страны изучаемого языка XVII века | Лекция | 248Ж | Гордеенок Т.М. | 25-РГФ-2 пт 11:45–13:10 | нед. 1-13 | История литературы страны изучаемого языка XVII века | Лекция | 248Ж | Гордеенок Т.М. | 25-РГФ-2
@@ -43,19 +46,20 @@
ср 10:05–11:30 | каждую неделю, нижняя строка | Коммуникативная грамматика | Практика | 304Г | Ковшик О.С. | 25-РГФ-1,25-РГФ-1/п1 ср 10:05–11:30 | каждую неделю, нижняя строка | Коммуникативная грамматика | Практика | 304Г | Ковшик О.С. | 25-РГФ-1,25-РГФ-1/п1
ср 10:05–11:30 | каждую неделю, нижняя строка | Коммуникативная грамматика | Практика | 308Г | Протас А.А. | 25-РГФ-1,25-РГФ-1/п2 ср 10:05–11:30 | каждую неделю, нижняя строка | Коммуникативная грамматика | Практика | 308Г | Протас А.А. | 25-РГФ-1,25-РГФ-1/п2
ср 10:05–11:30 | нед. 2-15 | История языка | Практика | 248Ж | Логвинова И.В. | 25-РГФ-2 ср 10:05–11:30 | нед. 2-15 | История языка | Практика | 248Ж | Логвинова И.В. | 25-РГФ-2
ср 11:45–13:10 | нед. с 2 | Русский язык как иностранный | Практика | | Немирская В.А. | 25-РГФ-1,25-РГФ-2 ср 11:45–13:10 | нед. с 2 | Русский язык как иностранный | Практика | 331Е | Немирская В.А. | 25-РГФ-1,25-РГФ-2 · иностранцам
ср 13:40–15:05 | нед. 2-15 | История языка | Практика | 305Г | Ковшик О.С. | 25-РГФ-1 ср 11:45–13:10 | нед. с 2 | ФИЗИЧЕСКАЯ КУЛЬТУРА | Занятие | | | 25-РГФ-1,25-РГФ-2 · кроме иностранцев
ср 13:40–15:05 | нед. 2-15 | История языка | Практика | 305Г | Ковшик О.С. | 25-РГФ-1 · кроме иностранцев
ср 15:20–16:45 | нед. 2-15 | Философия | Лекция | 211А | Борун Е.Н. | 25-РГФ-1,25-РГФ-2 ср 15:20–16:45 | нед. 2-15 | Философия | Лекция | 211А | Борун Е.Н. | 25-РГФ-1,25-РГФ-2
чт 08:30–09:55 | каждую неделю | Русский язык как иностранный | Практика | 307Г | Немирская В.А. | 25-РГФ-1,25-РГФ-1/п1 чт 08:30–09:55 | каждую неделю | Русский язык как иностранный | Практика | 307Г | Немирская В.А. | 25-РГФ-1,25-РГФ-1/п1 · иностранцам
чт 08:30–09:55 | каждую неделю, верхняя строка | Современный разговорный иностранный язык (англ.) | Практика | 304Г | Федорович Н.Я. | 25-РГФ-1,25-РГФ-1/п2 чт 08:30–09:55 | каждую неделю, верхняя строка | Современный разговорный иностранный язык (англ.) | Практика | 304Г | Федорович Н.Я. | 25-РГФ-1,25-РГФ-1/п2
чт 08:30–09:55 | каждую неделю, нижняя строка | Практика устной и письменной речи (англ.) | Практика | 304Г | Федорович Н.Я. | 25-РГФ-1,25-РГФ-1/п2 чт 08:30–09:55 | каждую неделю, нижняя строка | Практика устной и письменной речи (англ.) | Практика | 304Г | Федорович Н.Я. | 25-РГФ-1,25-РГФ-1/п2
чт 10:05–11:30 | каждую неделю, верхняя строка | История зарубежной литературы Средних веков и Возрождения | Лекция | 305Г | Лушневская Е.В. | 25-РГФ-1,25-РГФ-2 чт 10:05–11:30 | каждую неделю, верхняя строка | История зарубежной литературы Средних веков и Возрождения | Лекция | 305Г | Лушневская Е.В. | 25-РГФ-1,25-РГФ-2
чт 10:05–11:30 | каждую неделю, нижняя строка | История языка | Лекция | 305Г | Третьяк З.И. | 25-РГФ-1 чт 10:05–11:30 | каждую неделю, нижняя строка | История языка | Лекция | 305Г | Третьяк З.И. | 25-РГФ-1 · кроме иностранцев
чт 10:05–11:30 | каждую неделю, нижняя строка | Современный разговорный иностранный язык (нем.) | Практика | 248Ж | Белоусова Э.А. | 25-РГФ-2 чт 10:05–11:30 | каждую неделю, нижняя строка | Современный разговорный иностранный язык (нем.) | Практика | 248Ж | Белоусова Э.А. | 25-РГФ-2
чт 11:45–13:10 | каждую неделю | Английский язык | Практика | 248Ж | Лагун А.М. | 25-РГФ-2 чт 11:45–13:10 | каждую неделю | Английский язык | Практика | 248Ж | Лагун А.М. | 25-РГФ-2
чт 11:45–13:10 | каждую неделю | Основы перевода | Практика | 120Д | Смулькевич А.А. | 25-РГФ-1 чт 11:45–13:10 | каждую неделю | Основы перевода | Практика | 120Д | Смулькевич А.А. | 25-РГФ-1
чт 13:40–15:05 | даты 2026-09-10,2026-10-15,2026-11-19,2026-12-17 | Единый день информирования: 10 сентября, 15 октября, 19 ноября, 17 декабря | Занятие | | | 25-РГФ-1,25-РГФ-2 чт 13:40–15:05 | даты 2026-09-10,2026-10-15,2026-11-19,2026-12-17 | Единый день информирования: 10 сентября, 15 октября, 19 ноября, 17 декабря | Занятие | | | 25-РГФ-1,25-РГФ-2
чт 13:40–15:05 | каждую неделю | Практика устной и письменной речи (нем.) | Практика | 248Ж | Белоусова Э.А. | 25-РГФ-2 чт 13:40–15:05 | каждую неделю | Практика устной и письменной речи (нем.) | Практика | 248Ж | Белоусова Э.А. | 25-РГФ-2
чт 13:40–15:05 | нед. 1-13 | История литературы страны изучаемого языка XVII века | Практика | 319Д | Смулькевич А.А. | 25-РГФ-1 чт 13:40–15:05 | нед. 1-13 | История литературы страны изучаемого языка XVII века | Практика | 319Д | Смулькевич А.А. | 25-РГФ-1
чт 15:20–16:45 | каждую неделю | Немецкий язык | Практика | 208А | Найверт Ю.О. | 25-РГФ-1 чт 15:20–16:45 | каждую неделю | Немецкий язык | Практика | 208А | Найверт Ю.О. | 25-РГФ-1 · на выбор
чт 15:20–16:45 | каждую неделю | Практическая грамматика (нем.) | Практика | 248Ж | Белоусова Э.А. | 25-РГФ-2 чт 15:20–16:45 | каждую неделю | Практическая грамматика (нем.) | Практика | 248Ж | Белоусова Э.А. | 25-РГФ-2