Compare commits

..

15 Commits

Author SHA1 Message Date
297091cb0e feat: Add Settings and Subjects screens with theme reveal functionality
- Implemented SettingsScreen for user preferences including theme selection, reminders, and notifications.
- Created SubjectsScreen to display subjects with upcoming lessons and associated details.
- Introduced ThemeReveal for a smooth transition between light and dark themes.
- Added support for managing notes and tasks related to subjects.
- Enhanced UI components for better user experience and accessibility.
2026-10-24 02:47:37 +03:00
416527fb33 feat: Update parser to handle lesson type after teacher's name and add corresponding tests 2026-10-23 22:14:00 +03:00
b9c1f63301 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.
2026-10-23 21:56:07 +03:00
a3df53713f feat: add travel and room alerts, live lesson notifications, and launcher shortcuts
- Implemented travel alerts for notifying about classes in another town the evening before.
- Added room alerts for notifying about room changes on the morning of the class.
- Introduced live lesson notifications to provide ongoing updates during classes.
- Created launcher shortcuts for quick access to today's schedule, weekly overview, search functionality, and adding new tasks.
- Updated settings to manage new notification preferences and integrated them into the user interface.
- Enhanced notification channels for better organization and user experience.
2026-10-23 01:38:06 +03:00
02e6404731 feat: enhance lesson plan handling with new parser and color-coded room assignments 2026-10-23 00:33:13 +03:00
c0487e4c2a feat: enhance user-agent identification and implement backoff strategy for university source requests
- Added PSU_CONTACT environment variable to docker-compose for owner contact in User-Agent.
- Updated source.js to include user-agent construction with service version and contact information.
- Implemented exponential backoff strategy for failed requests to the university, with retry logic based on HTTP response status.
- Modified status.js to display backoff information in the status response.
- Adjusted watch.js to reduce request frequency during university downtime.
- Enhanced test coverage with new backoff.test.js to validate backoff behavior and user-agent format.
2026-10-22 01:17:54 +03:00
10c85e3bb9 feat: read "Занятия в Полоцке" as a day's town, show it in all clients
- Parser 14 (server/places.js): "Занятия в Полоцке/Новополоцке/г. Новополоцк" over a day's first row is no longer a lesson but a note of the day for its column (`places`); lessons of that day get `place`. A lesson below the note in the same cell keeps its own subject.
- A town on its own line ("Новополоцк", "г.Новополоцк") or after a room ("246(Новополоцк)") becomes the lesson's `place` and leaves the subject and room. "История Полоцка" is not a town.
- API 14: `place` on lessons and `places` on days of /api/timetable, `place` in the directory. The day notes are shared by the server and the PWA (shared/places.js); the same note in every subgroup reads as the whole group's.
- Android, PWA and iOS: a "Занятия в Полоцке" card above the day's lessons (day pages, calendar, overview, week view, desk stand), "Полоцк" under the date in the week grid, "249Ж · Полоцк" on lesson cards, a "Город" row in details, the town in widgets, tile, reminders and change lists.
- Snapshot lines show the town and the day notes; tests for notes, towns in cells and the API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 01:26:54 +03:00
3d08d0cb70 feat: enhance group management and error handling in the app
- Added `forgetRecent` function in `Settings` to remove groups no longer recognized by the server from the quick-switch list.
- Updated `Status` data class in `MainViewModel` to include a `code` property for better error categorization and introduced `needsGroup` logic to determine if a group needs to be reselected.
- Modified `HomeScreen` and `ScheduleScreen` to display a "Выбрать группу" action in the banner when a group is not found, improving user experience during errors.
- Refactored `Banner` composable to support dynamic actions, allowing for more contextual responses to user errors.
2026-10-21 00:51:14 +03:00
67bb2ea6fb feat(parser): update group parsing to handle special cases and improve regex patterns
feat(parser): enhance details extraction for lessons, including handling of subgroups and room moves

feat(timetable): integrate room and teacher retrieval based on week context

test(parser): add tests for group parsing with special characters and room move scenarios

test(formats): implement tests for various lesson formats and their expected outputs

feat(rooms): introduce room management functions for handling moves and week-based plans
2026-10-21 00:34:06 +03:00
3ae7b38fca feat: implement dashboard with authentication and event logging
- Added dashboard functionality at /status with Basic Auth using DASHBOARD_PASSWORD.
- Created server-side event logging to track catalog and university status.
- Implemented traffic metrics tracking and display on the dashboard.
- Enhanced API tests to cover new dashboard features and authentication.
- Introduced CSS and JS for dashboard styling and functionality.
- Added tests for dashboard access control and data retrieval.
2026-10-21 00:04:33 +03:00
de72d003b8 feat: Add request logging and traffic metrics for API performance monitoring 2026-10-20 23:31:08 +03:00
e28d65310d feat: Implement Telegram alerts for catalog updates and failures 2026-10-20 23:14:32 +03:00
a95784df93 fix: Update contact email for privacy policy page 2026-10-20 02:27:15 +03:00
7becdb0902 feat: Add privacy policy page and associated styles for user data transparency 2026-10-20 02:25:36 +03:00
b9a8f91e1f feat: Replace vector launcher icon with mipmap resources for improved performance and compatibility 2026-10-20 02:25:19 +03:00
163 changed files with 225717 additions and 194234 deletions

View File

@@ -12,6 +12,12 @@
"runtimeExecutable": "env",
"runtimeArgs": ["PORT=3002", "node", "--watch", "server/index.js"],
"port": 3002
},
{
"name": "vite-docker",
"runtimeExecutable": "env",
"runtimeArgs": ["API_PORT=3010", "npx", "vite", "--host", "127.0.0.1", "--port", "5174"],
"port": 5174
}
]
}

1
.gitignore vendored
View File

@@ -6,3 +6,4 @@ dist/
xcuserdata/
DerivedData/
*.xcuserstate
.env

148
README.md
View File

@@ -29,6 +29,7 @@ Vite работает на http://127.0.0.1:5173 и проксирует `/api`
- Отдельные карточки для альтернативных занятий и условий по неделям. Сохраняются исходный текст, цвет ячейки, подгруппы, даты и ограничения.
- Учебные недели по таблице университета: переключатель недель с датами, показ только занятий выбранной недели, статус занятия («ещё 4 занятия · до 27 нояб.», «начнётся 20 нояб.») и сетка недель в деталях. Режим «Все строки Word» показывает документ как есть.
- Номера пар и текущая пара («Идёт · ещё 25 мин») по расписанию звонков.
- **Занятия в другом городе.** Если в Word над днём написано «Занятия в Полоцке», над парами этого дня — в виде «День» и «Неделя» — стоит карточка «Занятия в Полоцке · пометка в Word на этот день» (с подгруппой, если заметка только её). В карточке пары город стоит рядом с аудиторией («Ауд. 249Ж · Полоцк»), в деталях — строка «Город». Сохранённые раньше расписания перечитываются по версии парсера и получают города сами.
- «Что изменилось»: когда университет перепубликует документ, приложение сравнивает новую версию с сохранённой и показывает для вашей группы изменённые поля (аудитория, время, преподаватель, недели), добавленные и удалённые занятия. Последний список изменений открывается из меню.
- Моя подгруппа и скрытые занятия: в «Группах» можно выбрать подгруппу — занятия другой подгруппы пропадут; любое занятие можно скрыть из его карточки и вернуть кнопкой «Скрыто · показать». Настройки хранятся только в этом браузере.
- **Поиск по университету** (вкладка «Поиск»): преподаватель или аудитория — занятия на выбранный день по всем расписаниям каталога; свободные аудитории на дату и пару. Сервер разбирает все документы каталога в фоне (первый раз около 20–30 секунд, дальше из кэша) и считает занятость с учётом недель, цвета строк, семестра и разовых переносов аудиторий вида «432 (07.10-ауд.272)». Дистанционные занятия («200 (ДО)») аудиторию не занимают. Каждый ответ сообщает, сколько документов учтено; «свободна» означает «свободна по расписаниям из Word», мероприятия и закрытые аудитории расписание не знает. Работает только онлайн.
@@ -43,13 +44,63 @@ Vite работает на http://127.0.0.1:5173 и проксирует `/api`
[Каталог университета](https://nextcloud.psu.by/index.php/s/no5zKbMrns6j5jQ) читается через публичный DAV. Сервер обращается только к этой общей папке. Другие ссылки не принимаются.
**Учебный год выбирается сам.** Сервер читает корень каталога, находит папки вида `2026-2027` и с 1 сентября (Europe/Minsk) берёт папку нового года. Если её ещё нет, используется самая новая из прошлых, а `/api/health` предупреждает об этом. Таблица белых и зелёных недель и расписание звонков ищутся по смыслу имени («бел» + «зел» + «недел», «звонк»), сначала в папке года, затем в корне. Можно выложить несколько таблиц недель: они объединяются, при совпадении дат побеждает более новый файл. В список расписаний эти справочные файлы не попадают.
**Учебный год выбирается сам.** Сервер читает корень каталога, находит папки вида `2026-2027` и с 1 сентября (Europe/Minsk) берёт папку нового года. Если её ещё нет, используется самая новая из прошлых, а дашборд `/status` предупреждает об этом. Таблица белых и зелёных недель и расписание звонков ищутся по смыслу имени («бел» + «зел» + «недел», «звонк»), сначала в папке года, затем в корне. Можно выложить несколько таблиц недель: они объединяются, при совпадении дат побеждает более новый файл. В список расписаний эти справочные файлы не попадают.
**Семестры.** Таблица делится на семестры там, где нумерация недель начинается заново или между неделями больше трёх недель. Расписание связывается со своим семестром по тексту Word («осенний семестр 2026/2027»), расписание сессии — по своим датам, документ без указания семестра — по семестру, в котором сегодняшняя дата. Если семестр не найден (например, весенней таблицы ещё нет), даты не рассчитываются и показываются все строки Word.
**Обновления у студента.** При запуске и при возвращении в приложение, если прошло больше часа, клиент сверяет сохранённое расписание с каталогом. Тот же файл с новой датой тихо перечитывается. Так же один раз перечитывается расписание, сохранённое более старым парсером, чем на сервере: `/api/catalog` сообщает `parserVersion`, и PWA сравнивает его с версией сохранённой копии. Поэтому исправления разбора доходят до студентов без перепубликации документа. У сервера без `parserVersion` перечитывания нет. Если для группы появился более новый документ (следующий семестр или год), показывается предложение его открыть; отказ запоминается. Если документ исчез из каталога, приложение предлагает найти группу заново.
Каталог и папка года перечитываются раз в 15 минут, таблица недель и звонки — раз в 6 часов, распознанный документ — при изменении даты файла или версии парсера. Устаревшая копия отвечает сразу, а перечитывание идёт в фоне. Ждать приходится только при первом запуске, в начале нового учебного года и при «Обновить». «Обновить» (`?refresh=1`) принудительно перечитывает всё, но обращается к университету не чаще раза в 2 минуты (таблицу недель — раз в 10 минут), иначе отвечает только что прочитанной копией. После сбоя источника фоновые попытки тоже ждут 2 минуты. Если источник недоступен, используются сохранённые копии с явной пометкой. Фоновая синхронизация при закрытом приложении не реализована.
**Проверка каталога по ETag.** Сервер сам проверяет каталог университета раз в 5 минут, а не только когда приходит запрос. У каждой папки Nextcloud есть ETag, и он меняется, когда меняется что‑то внутри. Сервер хранит листинги прошлого обхода (`.data/catalog-tree.json`) и заново запрашивает только папки с новым ETag. Проверка неизменного каталога — 2 запроса вместо ~100: корень и папка года. Перезалитый документ стоит ещё пары запросов по пути к нему. Раз в 6 часов на всякий случай идёт полный обход. Индекс пересобирается, только если изменился какой‑то документ, поэтому ETag расписаний и кэши клиентов между проверками не сбрасываются. На дашборде (поле `catalog.lastCrawl`) видно последний обход: число запросов, полный ли он, сколько папок взято из кэша. Поле `watch` там же — время и ошибка последней проверки.
**Вежливость к университету.** Каждый запрос к Nextcloud представляется: `User-Agent: PSU-Schedule/0.1.0 (+<PSU_PUBLIC_URL>; <PSU_CONTACT>)`. Например, `PSU_CONTACT=mailto:you@example.com` — тогда администраторы университета видят, чей это сервис и кому написать, а не безымянного робота. Когда университет перестаёт отвечать (нет соединения, `502`–`504`, `429`, долго не отвечает листинг папки), сервер делает паузу: до её конца к университету не уходит ни один запрос — ни от наблюдателя, ни от «Обновить» в клиентах, ни при открытии документа. Студенты получают сохранённую копию. Пауза удваивается после каждой неудачной попытки: 5, 10, 20, 40 минут, дальше раз в час (`PSU_SOURCE_BACKOFF_MS`, `PSU_SOURCE_BACKOFF_MAX_MS`), и учитывает `Retry-After`. Первый же ответ её снимает. Скачанные документы сервер хранит у себя (`.data/documents/`), поэтому новая версия парсера перечитывает их локально, не обращаясь к университету. Ошибка одного документа (`404`, `500`) и медленная загрузка файла паузу не включают. На дашборде у плитки «Университет» видно время следующей попытки, в `/status/data.json` — `catalog.backoff`.
**Дашборд `/status`.** Страница состояния сервера за паролем `DASHBOARD_PASSWORD`. Вход через обычное окно браузера, имя пользователя любое. Без пароля дашборд выключен и отвечает `404`. После десяти неверных паролей с одного адреса вход блокируется на 10 минут. Страница не кэшируется и закрыта от поисковиков (`noindex`). На ней:
- **плитки состояния** — сервер, каталог (год, число документов, последняя проверка и её стоимость в запросах), университет (отвечает ли, с какого времени не отвечает), разбор (подозрительные ячейки, нечитаемые документы), Telegram;
- **запросы за последний час** — ключевые числа, график по минутам (с ответом, `304`, ошибки), адреса API со средним временем и самые активные клиенты;
- **лента событий** — новые, перезалитые и нечитаемые документы, смена учебного года, сбои университета; последние 100 событий хранятся в `.data/events.json`;
- **подозрительные ячейки** — по документам, с исходным текстом ячейки и ссылкой на оригинал, с фильтрами по виду и поиском по группе, документу или тексту.
Страница — обычные HTML, CSS и JS из `server/status/` без сборки, под той же строгой CSP. Она обновляется раз в 30 секунд, пока вкладка открыта. Данные берёт из `/status/data.json` за тем же паролем. Публичный `/api/health` после появления дашборда отвечает только «жив, готов, версии, учебный год»: остальное называет документы, клиентов и настройки владельца.
**Журнал запросов и метрики.** На каждый запрос к `/api` в журнал (`docker logs`) пишется одна строка JSON: время, метод, путь с параметрами, статус, время ответа в мс, код ошибки, размер ответа и адрес клиента без последней части (`203.0.113.0`, у IPv6 — /48). Точные адреса не сохраняются. Проверки здоровья (`/api/health`, `/api/ready`) не пишутся и не считаются. Журнал выключается переменной `PSU_REQUEST_LOG=off`.
Поле `traffic` на дашборде (`/status/data.json`) — сводка за последний час (`lastHour`) и с запуска (`total`):
- сколько было запросов и какая доля ушла пустым `304`;
- ошибки клиента и сервера;
- срабатывания лимита (`limited`) и таймаута (`timeouts`);
- ответы `502`/`504` из‑за университета (`sourceErrors`) и упавшие запросы к нему (`sourceFailures`);
- медленные ответы, дольше секунды;
- медиана и 95‑й процентиль времени ответа — как граница корзины: 25, 50, 100, 250… мс;
- десять самых частых адресов API со средним временем.
За последний час есть ещё число разных клиентов и три самых активных. Так видно, если кто‑то перебирает API.
**Оповещения в Telegram.** Если заданы `TELEGRAM_BOT_TOKEN` и `TELEGRAM_CHAT_ID`, сервер пишет владельцу после проверок каталога:
- появился новый документ — с его группами и подозрительными ячейками;
- документ не читается;
- документ перезалит, и в нём появились подозрительные ячейки, которых раньше не было;
- каталог перешёл на новый учебный год, или папка нового года задерживается;
- университет не отвечает дольше часа, а потом снова отвечает.
Первая проверка только сообщает, что оповещения включены, и называет число документов и подозрительных ячеек. Если сообщение не ушло, оно повторится при следующей проверке. С `PSU_PUBLIC_URL` в сообщениях есть ссылка на `/api/diagnostics` документа.
Настройка:
1. Создайте бота у @BotFather и получите токен.
2. Напишите боту любое сообщение.
3. Возьмите `chat.id` из ответа `https://api.telegram.org/bot<токен>/getUpdates`.
4. Положите значения в `.env` рядом с `docker-compose.yml` (файл в `.gitignore`):
```sh
TELEGRAM_BOT_TOKEN=123456:ABC...
TELEGRAM_CHAT_ID=123456789
PSU_PUBLIC_URL=https://psu.rumblur.by
```
5. Перезапустите контейнер: `docker compose up -d`.
Состояние оповещений — плитка Telegram на дашборде и поле `telegram` в `/status/data.json`: включены ли, сколько отправлено и последняя ошибка. Токен в журналы и в `/api/health` не попадает.
Каталог перечитывается раз в 5 минут (по ETag), папка года — раз в 15 минут, таблица недель и звонки — раз в 6 часов, распознанный документ — при изменении даты файла или версии парсера. Устаревшая копия отвечает сразу, а перечитывание идёт в фоне. Ждать приходится только при первом запуске, в начале нового учебного года и при «Обновить». «Обновить» (`?refresh=1`) принудительно перечитывает всё, но обращается к университету не чаще раза в 2 минуты (таблицу недель — раз в 10 минут), иначе отвечает только что прочитанной копией. После сбоя источника фоновые попытки тоже ждут 2 минуты. Если источник недоступен, используются сохранённые копии с явной пометкой. Фоновая синхронизация при закрытом приложении не реализована.
Ответы API сжимаются (gzip или brotli; семестр группы — около 200 КБ JSON, в gzip — около 5 КБ) и отдаются с `Cache-Control: no-cache` и `ETag`. Клиент хранит ответ, но каждый раз спрашивает заново. Если ничего не изменилось, приходит пустой `304`. Android-клиент делает это сам через дисковый кэш OkHttp. Импорт Word ограничен: два разбора одновременно, ещё четыре ждут, остальным — `429`; с одного адреса — до 10 файлов за 10 минут. За прокси (Caddy, туннель) адрес клиента берётся из `X-Forwarded-For`, если прокси находится в локальной или частной сети. Для `/api/timetable` ETag считается до любой работы — из версии документа, парсера и сервера, таблицы недель, параметров запроса и даты, — поэтому неизменённое расписание отвечает `304` примерно за миллисекунду, не открывая документ.
@@ -63,7 +114,7 @@ Vite работает на http://127.0.0.1:5173 и проксирует `/api`
- **Заголовки безопасности.** Все ответы несут строгую `Content-Security-Policy`: только свои скрипты, стили, шрифты и запросы, а единственный inline-скрипт (тема до первой отрисовки) разрешён по хэшу, который сервер считает из собранного `dist/index.html`. Кроме неё: `X-Content-Type-Options`, `X-Frame-Options: DENY`, `Referrer-Policy`, `Permissions-Policy`, `Cross-Origin-Opener-Policy`. За HTTPS-прокси, который передаёт `X-Forwarded-Proto`, добавляется `Strict-Transport-Security`.
- **Адрес клиента за прокси.** Лимит считается по настоящему адресу клиента. Сервер верит `X-Forwarded-For` только от прокси из локальной сети (`127.0.0.1`, `10.*`, `172.16–31.*`, `192.168.*`). Поле `client` в `/api/health` показывает, кем сервер тебя видит: если там адрес прокси, а не твой, все посетители делят один лимит, и прокси нужно настроить на передачу `X-Forwarded-For`.
`GET /api/ready` отвечает `200`, когда каталог, таблица недель и индекс всех документов загружены, а до этого — `503` с кодом `not_ready` и списком того, что ещё грузится (`waiting`). На него смотрят проверка здоровья Docker и прокси. `GET /api/health` всегда отвечает `200`, пока процесс жив (поле `ready` — то же, что `/api/ready`), и без обращения к сети показывает выбранный учебный год, возраст каталога, найденные файлы недель и звонков, семестры и предупреждения: папка нового года не найдена, сегодняшняя дата вне таблицы недель, источник не обновляется.
`GET /api/ready` отвечает `200`, когда каталог, таблица недель и индекс всех документов загружены, а до этого — `503` с кодом `not_ready` и списком того, что ещё грузится (`waiting`). На него смотрят проверка здоровья Docker и прокси. `GET /api/health` всегда отвечает `200`, пока процесс жив: поле `ready` — то же, что `/api/ready`, плюс версии и учебный год. Возраст каталога, найденные файлы недель и звонков, семестры и предупреждения (папка нового года не найдена, сегодняшняя дата вне таблицы недель, источник не обновляется) — на дашборде `/status`.
```sh
npm run sync
@@ -92,11 +143,62 @@ npm run sync
Поля предмета, преподавателя и аудитории извлекаются по типовым обозначениям; сложные многострочные записи могут содержать несколько преподавателей/аудиторий. Поэтому полный текст всегда доступен в деталях. Если в ячейке явно указано особое время, например `16.50` для кураторского часа, `9.00`, `18.35.`, интервал `11:10-11:30` или несколько времён (`17.00 и 18.35`, `18.35+20.10` — тогда это отдельные занятия), оно имеет приоритет; окончание не выдумывается. Дата в начале ячейки (`13.10`, `3.12`, `24.11.`, `12.09.26г.`) делает запись разовой. Запись вида `20.10` может быть и датой, и началом восьмой пары: если такая дата приходится на другой день недели, чем строка, или после неё идёт пометка недель, это время.
**Аудитории и преподаватели по неделям и датам** (`server/rooms.js`):
- **Перенос на дату.** «412н 26.10.2026‑333», «402н (13.10‑ауд.510н)», «14.10. ‑ауд. 333» — в этот день занятие идёт в другой аудитории, в остальные дни — в обычной. Год в дате необязателен. Расписание по датам отдаёт аудиторию дня с пометкой `roomMoved`.
- **План по неделям.** Строка под занятием, где есть только недели, аудитории или преподаватели, продолжает это занятие, а не становится новым. Например: «1‑9н. 502н / с 10н. – 359», «1‑13н‑332, 14‑17н.‑502н», «2‑9н.‑Клос О.Ю., 10‑14н.‑Конторов В.Э. 319Д». Аудитория в конце такой строки относится ко всем её частям.
- **План по цвету недели.** «бел.н.504н/зел.н.524н», «б.н.301Г/з.н.317Д» и с цветом после аудитории — «ауд.533н-б.н.,420н-з.н.» (25-ДО, 26-ДО, 23-ДО, АРХ, ПГС, ТТиИ и др., парсер 15). Номер аудитории перед пометкой цвета («32н-з.н.») не принимается за 32-ю неделю.
- **Как отдаётся.** План лежит у занятия в поле `plan`. Расписание по датам и справочник «Кто где» берут аудиторию и преподавателя той недели; PWA — тоже (общий `planFor` из `shared/lessons.js`), а в режиме «Все строки Word» показывает аудитории обоих цветов: «533Н (бел. н.), 420Н (зел. н.)». Весь план виден в деталях занятия. В неделе, которой нет в плане, не показываются аудитории и преподаватели, указанные только для других недель.
**Ещё форматы ячеек:**
- **Вид без предмета.** Строка «7,9,11н. – (п.з.) Савицкая К.Д. … 8,12,14н. – (п.з.) …» — тот же предмет, что выше, в другие недели, с этим видом.
- **Разный вид по неделям.** «1‑11н. ‑ л., 12‑14 – п.з. Защита населения…» — лекция на одни недели, практика на другие.
- **Дисциплины по выбору и специализации.**
- Заголовок «Дисциплины по выбору:» или «Дисциплина специализации: 4‑14 н. …» — не занятие, и он не скрывает недели.
- Строка «Социология 1‑10 н. (л.) … 11‑16 н. (п.з.) …» с неделями после названия становится занятиями по видам.
- Строка с предметом и аудиторией над строками по видам — заголовок: её аудитория переходит к занятиям под ней.
- **Совмещённый вид.** «(л./п.з.)» и «(л. и п.з.)» — вид «Лекция и практика».
- **Подгруппа в тексте.** «12н.2п/гр. Экономика…» — занятие одной подгруппы.
- **Разрядка** (парсер 19): «Ф и з и ч е с к а я к у л ь т у р а», «З а н я т и я в П о л о ц к е» — буквы через пробел, слова через несколько. Склеивается до сжатия пробелов: «Физическая культура», «Занятия в Полоцке» (теперь это и заметка о городе). Инициалы и короткие слова не трогаются.
- **«со 2 по 14 нед.»** читается как «2-14 нед.»; пробелы внутри кавычек («« Майнор »») убираются.
- **Вид занятия после преподавателя** (парсер 18): «Русский язык как иностранный Козыренко А.Н.(п.з.) ауд.100» — название кончается там, где начинается преподаватель или вид, смотря что раньше. Раньше фамилия попадала в название (25‑ЭПи, 25‑ЭМ).
- **Ячейка из одной точки** — не занятие.
- **Время в начале ячейки:**
- интервал с дефисом «17:00‑17:20‑информационный час»;
- заголовок со временем «18.35+20.10 Новополоцк:» передаёт время занятиям под ним.
- **Длинная запись под заголовком заглавными** («ВВЕДЕНИЕ В ВЫСШЕЕ ОБРАЗОВАНИЕ / г. Полоцк, …») называется этим заголовком.
**Группы с направлением в скобках** — «24‑ИЯ (РА)и‑1» … «24‑ИЯ (РА)и‑6» — разные группы. Они пишутся, как у университета, с пробелом перед скобкой. До версии парсера 13 они сливались в одну «24‑ИЯ(РА)» со всеми занятиями трёх групп. Студентам с такой сохранённой группой нужно выбрать свою группу заново. На старое имя `/api/timetable` отвечает `404 group_not_found` и в тексте ошибки называет новые группы. Имя без пробела («24‑ИЯ(РА)и‑1») по‑прежнему находит группу.
**Занятия в другом городе** (`server/places.js`, парсер 14). Большинство пар — в Новополоцке, но часть факультетов учится в Полоцке, и Word отмечает это по‑разному:
- **«Занятия в Полоцке» над первой парой дня** (24‑ИЯ (РА)и‑1…6, 25‑ИЯ (РА)и‑3, 24‑РГФи‑1, 22‑ЛОМК, 25‑ТДи) — не занятие, а заметка дня для столбца: в этот день все пары группы или подгруппы идут в том городе (и аудитории у них полоцкие — Г, Д, Е, Ж). Заметка лежит в разобранном документе в `places` (`{day, date, groups, subgroups, place, text}`), занятиям этого дня проставляется `place`. Если в той же ячейке под заметкой есть пара, это обычное занятие без приставки в названии. Занятие всей группы получает город, когда заметка стоит во всех её подгруппах.
- **Город строкой в ячейке занятия** — «Новополоцк», «г.Новополоцк» — или **после аудитории** — «246(Новополоцк)», «121(г.Новополоцк)» — город этого занятия; из названия и аудитории он убирается.
- **Заметка посреди дня** (25‑ИТ, пятница: «Занятия в Полоцке» в строке 13:40) действует с этой пары: в разобранном документе у неё `from`, утренние пары города не получают. `/api/timetable` отдаёт `from` в `places`, Android пишет «С 13:40 — занятия в Полоцке».
- Пишется и «Занятия в г. Новополоцк», «Занятия проходят в …», «Занятия в Междуречье». Город приводится к именительному падежу («Полоцке» → «Полоцк»). Название предмета вроде «История Полоцка» городом не считается.
- В документах «Введение в высшее образование» строка «г. Полоцк, Дом офицеров» даёт занятию город, а место проведения остаётся в тексте.
- Какие заметки показать над днём группы, решает общий модуль `shared/places.js`: им пользуются и `/api/timetable`, и PWA. Одинаковая заметка во всех подгруппах сводится к одной для всей группы, а заметка своей подгруппы показывается как заметка всего дня.
**Занятия на выбор** (парсер 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.
PDF, Excel, изображения и сканы пока не импортируются. Они не выдаются за распознанное расписание.
**Подозрительные ячейки.** После разбора каждый документ каталога проверяется (`server/diagnostics.js`), и `GET /api/diagnostics` показывает ячейки, которые прочитаны неуверенно, с исходным текстом. Фильтры: `?group=23-АРХ`, `?doc=<id>`, `?code=weeks_partly`. Число таких ячеек есть и в `/api/health` (`directory.problems`). Коды:
**Подозрительные ячейки.** После разбора каждый документ каталога проверяется (`server/diagnostics.js`), и `GET /api/diagnostics` показывает ячейки, которые прочитаны неуверенно, с исходным текстом. Фильтры: `?group=23-АРХ`, `?doc=<id>`, `?code=weeks_partly`. Их число и список с фильтрами — на дашборде `/status`. Коды:
- `weeks_partly` — пометка недель прочитана не целиком (`1-4н.;6-12н.`);
- `weeks_unreadable` — пометка недель не прочитана (`2,6-12-16н`);
- `weeks_like_date` — «недели» похожи на дату с опечаткой (`19,11, 03.12`);
@@ -150,7 +252,7 @@ DOMAIN=schedule.example.by docker compose --profile https up -d --build
В папке `android/` — нативный клиент на Kotlin и Jetpack Compose с компонентами Material 3 Expressive (`MaterialExpressiveTheme`, `ModalWideNavigationRail` и `WideNavigationRail`, соединённые `ToggleButton`, `LoadingIndicator`). Базовая тема синяя (тональная схема M3); с «Цветами обоев» — динамическая, а цвета предметов и зелёной недели гармонизируются с ней. Разделы открываются из бокового меню: на телефоне оно выезжает, на планшете в портрете и на телефоне в альбомной ориентации — узкая боковая панель, на планшете в альбомной ориентации меню открыто постоянно. На широком экране разделы делятся на две панели, как в School Planner: «Обзор» — карточки и недельная сетка, «Планы» и «Календарь» — список и выбранный день, «Поиск» — результаты и день преподавателя или аудитории; «Предметы» — сеткой карточек, «Расписание» открывается неделей; карточки лежат на подложке с оттенком, системные панели следуют теме приложения.
**Складные телефоны.** Приложение следит за сгибом экрана через Jetpack WindowManager (`FoldingFeature`, `ui/Fold.kt`).
**Складные телефоны.** Приложение следит за сгибом экрана через Jetpack WindowManager (`FoldingFeature`, `ui/components/Fold.kt`).
- **Фолды** (Galaxy Z Fold, Pixel Fold) в разложенном виде — окно через сгиб с короткой стороной от 600dp — получают планшетный макет:
- «Обзор», «Планы», «Календарь» и «Поиск» — две панели, как две страницы, по обе стороны вертикального сгиба, и в плоском, и в полураскрытом виде;
- «Расписание» открывается неделей на карточке;
@@ -163,23 +265,51 @@ DOMAIN=schedule.example.by docker compose --profile https up -d --build
Проверено на эмуляторе 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). Пара открывается из своей карточки: из «Обзора», расписания по дням, недельной сетки, календаря «Планов» и подставки «ноутбука». Рамка плавно растягивается до полного экрана (на планшете — до большой панели по центру), а содержимое едет вместе с ней, как в M3 (container transform из Material Components): и карточка, и страница подробностей масштабируются по ширине рамки от её левого верхнего угла, так что заголовок и сведения о паре выходят из самой карточки. Страница проявляется поверх карточки за первую четверть пути, углы выпрямляются за три четверти; при закрытии она гаснет поздно, между 60 и 90 % пути, и рамка с карточкой внутри сжимается на место. «Назад», предиктивный жест и касание за панелью сворачивают её обратно в карточку. Так же «+» в «Планах» и календаре раскрывается в лист нового задания; лист можно стянуть вниз за ручку, и он тоже сжимается обратно в «+». Контейнер рисует только содержимое источника (иконку «+», текст карточки), а заливку, углы и тень — свои: у «+» тень плавно уходит при росте и возвращается к его уровню при посадке, так что ни второй формы внутри, ни скачка тени в конце нет.
- **Живая текущая пара.** Вокруг идущей пары по часовой стрелке от левого верхнего угла рисуется рамка: какая часть пары прошла. Она подрастает плавно, без скачков. Цифры обратного отсчёта («ещё 44 мин», «Через 15 мин») прокручиваются, как на табло. Линия «сейчас» в недельной сетке ползёт вниз вместе со временем.
- **Лента дней.** Выбранный день — один круг под всей лентой: при выборе другого дня он перетекает туда на пружинах, передний край спешит, задний догоняет, и по дороге круг вытягивается в каплю. В «Расписании» точка в круге дня значит «есть пары». Когда пролистываешь на другую неделю, её точки появляются по очереди, с понедельника.
- **Долгое нажатие на пару** (в расписании, недельной сетке и «Обзоре») открывает лист действий: «Подробнее» (страница вырастает из той же карточки), «Задание или заметка», «Был / Пропустил», когда пара уже началась, «Поделиться» (предмет, вид, день, время, аудитория, преподаватель) и «Скрыть такие занятия» с «Вернуть». Для TalkBack — действие «Действия» у карточки.
- **Между парами** в расписании дня — строка, когда есть что сказать: «Окно 1 ч 35 мин» (от 45 минут, то есть пропущена пара), «Другой корпус: 211А → 254В · перерыв 30 мин» (корпус — буквы после номера аудитории), «Переезд в Полоцк» по городу пары; переезд с перерывом меньше часа выделен красным. Обычные перерывы 10–30 минут и дистанционные пары строки не дают.
- **Избранное и недавние в поиске.** Звёздочка у преподавателя или аудитории добавляет их в «Избранное»; до ввода запроса поиск показывает «Избранное» и «Недавние» (последние восемь открытых). Касание открывает сегодняшний день, и у идущей пары — «Идёт · ещё 40 мин» с рамкой прогресса, у ближайшей — «Через 15 мин»: где преподаватель сейчас, видно сразу.
- **Свайпы по заданиям** (как в Buckwheat). Вправо — «Выполнено», у выполненного — «Не выполнено». Влево — «Перенести»: задание переезжает к следующей паре того же предмета, общий план — на завтра; внизу «Перенесено на пн, 26 октября, 15:20 · Вернуть». Под строкой открывается прямоугольная полоса с иконкой действия; за порогом цвет действия заливает её кругом от иконки, иконка подпрыгивает, телефон щёлкает. Отпущенная строка всегда пружинит на место. Пока студент ни разу не свайпнул задание, одна строка на экране сама покачивается в стороны и показывает действия под собой; касание её останавливает, первый свайп убирает подсказку насовсем. На «Доске» свайпов нет: там жест листает дни. Для TalkBack те же действия — в меню действий строки.
- **Смена темы.** «Светлая», «Тёмная» и «Цвета обоев» в настройках меняют тему кругом, который расходится от нажатой кнопки: снимок старого экрана лежит поверх приложения, и в нём растёт дыра с новой темой (`ui/theme/ThemeReveal.kt`). Значки строки состояния рисует система, они меняются сразу.
- **Выполненное задание.** При отметке текст зачёркивается слева направо, строка за строкой, и задание чуть задерживается на месте, прежде чем уйти к выполненным. Когда закрыто последнее открытое, у «Всё сделано» выскакивает галочка.
- **Обновление жестом.** Потянуть вниз в «Обзоре» и в расписании: экспрессивный индикатор M3 с меняющейся формой и щелчок отклика, когда тянуть уже достаточно.
- **Свежие изменения на виду.** Если университет перезалил документ и пары изменились или добавились, их карточки в расписании дважды вспыхивают цветом (один раз, при первом показе).
- Первый запуск — короткий онбординг: приветствие с тремя возможностями приложения, затем шаги с волнистым индикатором прогресса: группа, подгруппа (только если группа делится на подгруппы) и напоминания. Разрешение на уведомления спрашивается на шаге напоминаний, его можно отложить; «Назад» (с предиктивным жестом) возвращает на предыдущий шаг. После онбординга открывается «Обзор» выбранной группы.
- Обзор: отчёт за неделю (сколько пар прошло), следующие занятия сегодня с обратным отсчётом и ближайший учебный день.
- Расписание группы по дням с листанием или недельной сеткой по часам; неделя и её цвет, номера пар, текущая пара, детали занятия с исходной записью Word. Если семестр не найден в таблице недель — строки Word по дням недели.
- **Занятия на выбор** (немецкий или французский у РГФ). После выбора группы (и подгруппы) приложение спрашивает «Что вы изучаете?», в онбординге это отдельный шаг. Пары другого варианта скрываются из расписания, «Обзора», «Планов», виджетов, напоминаний и уведомлений. Изменить выбор можно в «Группах», в карточке «Сейчас открыта». Пока вариант не выбран, видны обе пары с меткой «на выбор».
- **Иностранные студенты.** В группах, где есть пары только для иностранцев («Русский язык как иностранный») или только для остальных, приложение спрашивает «Вы иностранный студент?» (и в онбординге). Ответ «Да» скрывает пары «кроме иностранных студентов», «Нет» — РКИ и другие пары для иностранцев. Изменить — в «Группах», строка «Иностранный студент». На карточке пары — метка «для иностранных студентов» или «кроме иностранных студентов».
- Предметы: пары по дням текущей недели; у каждого предмета — сокращение как в недельной сетке, следующее занятие с аудиторией, преподаватели и сколько пар на этой неделе. У каждого предмета свой постоянный цвет во всех разделах.
- Планы: задания и заметки по дням — к занятию или общие, без привязки к паре, на выбранный день, просроченные сверху, «+» прикрепляет задание к ближайшему занятию. Три макета, как в School Planner: «Повестка дня» (все дни с липкими заголовками и полосой недели, которая следует за списком), «Список» (только дни с делами) и «Доска» (колонки дней). В настройках вида — пары дня и выполненные задания.
- Календарь: месяц с отметками дней с парами и с планами, зелёные недели подсвечены; под ним лист с планами и парами выбранного дня. Лист можно поднять — месяц сворачивается до выбранной недели.
- Обратный отсчёт до сессии в «Обзоре»: дни до ближайшего экзамена или зачёта из `/api/exams` (консультации — только в списке), по нажатию — вся сессия. Пока университет не опубликовал сессию, отсчёт идёт до конца семестра. Экзамены кэшируются на устройстве.
- **Занятия в другом городе.** Если в Word над днём написано «Занятия в Полоцке», над парами дня появляется карточка «Занятия в Полоцке · пометка в Word на этот день» — на странице дня, в «Календаре», в «Обзоре» (сегодня и ближайший день) и на подставке «ноутбука», а в недельной сетке под датой — метка «Полоцк». Если заметка относится к одной подгруппе, а выбраны все, в карточке указана подгруппа. В карточках пар город стоит рядом с аудиторией («249Ж · Полоцк»), в подробностях — строка «Город»; так же — в виджетах, плитке и напоминаниях. Раньше такая заметка показывалась отдельной парой «Занятия в Полоцке».
- **Группа, которой больше нет.** Если сервер отвечает `group_not_found` или `document_not_found` — группу разделили («24‑ИЯ(РА)» стала «24‑ИЯ (РА)и‑1»…), или документ ушёл из каталога, — плашка с текстом сервера предлагает «Выбрать группу». Сохранённая копия остаётся на экране, а устаревшая группа убирается из недавних.
- **Изменения в расписании.** Как в PWA: когда университет перезаливает документ группы, приложение сравнивает новое расписание с копией на устройстве. В «Обзоре» появляется карточка «Расписание обновлено · N изменений» с кнопкой «Показать», а в списке видно, что изменилось, что добавлено и что удалено, например «Аудитория: 374 → 375». Карточка показывается до просмотра или закрытия, последний список остаётся в настройках («Последние изменения»). Сравнение срабатывает и при фоновом обновлении (`SyncWorker`, раз в 6 часов). Если изменения нашлись в фоне, приходит уведомление «Расписание 23-АРХ изменилось» с первыми изменениями, например «Проектирование общественных зданий: аудитория 374 → 375». Уведомление локальное, сервер push для него не нужен. Его можно выключить в настройках («Изменения в расписании», по умолчанию включено), а на Android 13+ нужно разрешение на уведомления. Считается только новая версия того же файла: смена версии парсера или переход на другой документ изменениями не считаются. Сравниваются строки Word по дням недели и разовые занятия со своей датой, а не каждая рассчитанная дата.
- Задания и заметки к паре: из карточки занятия — к этой паре или к следующей паре того же предмета. Задания отмечаются выполненными, просроченные подсвечиваются; список — в «Обзоре», по предмету — в «Предметах», точка на блоке в недельной сетке. Хранятся только на устройстве (`notes.json`) и привязаны к группе.
- **Фото к заданиям:** в редакторе задания или заметки — «Снять» (приложение камеры) и «Галерея» (системный выбор фото, без доступа к памяти), до 6 фото. Фото уменьшается до 2048 px по длинной стороне, поворачивается по EXIF и хранится в `files/photos/` рядом с заметками. Удаление фото, задания или отмена правки убирает файлы, а при запуске удаляются фото, на которые не ссылается ни одна заметка. В списках у задания миниатюра (с «+N»), на карточке пары — значок фото. Просмотр — на весь экран, с листанием, масштабом щипком и двойным касанием. Он открывается по M3 как container transform для «героического» момента: фото вырастает из миниатюры (в строке задания или в редакторе) из квадратной обрезки в целый снимок, углы выпрямляются, вокруг поднимается чёрный фон, а при закрытии (крестик, «Назад», предиктивный жест) фото сжимается обратно в свою миниатюру; у фото без своей миниатюры — в ту, из которой открыли, и на последнем отрезке перетекает в её снимок. Миниатюра запоминает место на экране, поэтому переход работает и из диалога редактора. Задание может быть и одним фото, без текста.
- Посещаемость: «был» или «пропустил» в карточке занятия и одним касанием в «Обзоре» для прошедших сегодня пар; статистика по каждому предмету в «Предметах». Хранится на устройстве (`attendance.json`).
- Скрытие занятий: «Скрыть такие занятия» в карточке занятия убирает предмет с этим типом занятия из расписания, обзора, виджетов и напоминаний; вернуть можно в настройках.
- Второй виджет «Следующая пара» (4×1) и плитка в шторке быстрых настроек с текущей или следующей парой и аудиторией.
- **Виджет «Неделя»** (4×2 и больше): номер и цвет недели, по плитке на день (Пн–Сб, воскресенье — только если в нём есть пары) с числом пар. Сегодня выделено. Значок города — день в другом городе, значок стрелок — перенос аудитории, внизу строка вроде «Полоцк: вт, пт · перенос: пн, чт». Если растянуть виджет в высоту, в плитках появляются сокращения пар, как в недельной сетке. В воскресенье виджет показывает следующую неделю. Нажатие на день открывает этот день, на заголовок — неделю.
- Настройки отображения: цвета предметов, преподаватели, аудитории, номера пар, метки занятий и пометки из Word можно скрыть; полные сведения остаются в карточке занятия.
- Группы (кнопка группы в верхней панели «Обзора»; если у группы есть подгруппы, после выбора приложение предложит свою): поиск по номеру во всём каталоге, недавние, каталог по факультету и курсу, выбор подгруппы.
- Поиск по университету: занятия преподавателя или аудитории на выбранный день.
- Виджет «Сегодня» (Glance): текущая и следующая пара, обновляется каждые 15 минут без сети.
- Напоминания перед парой за 5–30 минут — локальные будильники из сохранённого расписания, без push-сервера. С разрешением «Будильники и напоминания» приходят точно в срок, без него — в окне ±5 минут.
- **Уведомления о дне** (настройки → «Уведомления о дне», тоже локальные, из сохранённого расписания):
- «Занятия в другом городе» — вечером накануне такого дня (18:00–21:00, по умолчанию 20:00) приходит «Завтра занятия в Полоцке» с первой парой, аудиторией и числом пар. Напоминание о первой паре этого дня, если напоминания включены, приходит раньше обычного на 0–60 минут (по умолчанию 30).
- «Переносы аудиторий» — утром в день переноса (6:30–8:00, по умолчанию 7:00, но не позже чем за час до пары) приходит «Сегодня перенос аудитории: 15:20 Возрастная физиология… — ауд. 100, обычно 402Н».
- Оба включены по умолчанию. Нажатие открывает этот день в «Расписании».
- **Текущая пара в шторке** (выключена по умолчанию): пока идёт пара, в шторке висит уведомление — предмет, «до 11:30 · ауд. 205Г · Полоцк», следующая пара и прогресс по половинам пары из расписания звонков. На Android 16 это Live Update: в строке состояния плашка с обратным отсчётом до конца пары, на экране блокировки — само уведомление. Оно появляется по будильнику в начале пары, обновляется раз в несколько минут и само исчезает в конце.
- **Ярлыки на иконке** (долгое нажатие): «Пары на сегодня», «Неделя», «Найти аудиторию», «Новое задание» (открывает «Планы» с выбором пары).
- Офлайн: последнее расписание хранится на устройстве; каждые 6 часов WorkManager обновляет его, виджет и напоминания.
**Экзамены, зачёты, консультации и защиты курсовых** попадают в «Расписание», «Календарь» и «Планы», даже если университет публикует их отдельным документом («Расписание зачетов»): `/api/timetable` (API 6) добавляет к дням семестра события сессии группы из всех документов каталога и продлевает период до последнего из них (не дальше 60 дней после семестра).
@@ -187,7 +317,7 @@ DOMAIN=schedule.example.by docker compose --profile https up -d --build
- **В «Планах»** они видны всегда, даже с выключенными «Парами дня».
- **В календаре** день с экзаменом или зачётом отмечен янтарным квадратом, а точка планов стала фиолетовой.
Даты считает сервер: `GET /api/timetable?doc=<id>&group=<группа>&from=ГГГГ-ММ-ДД&days=N` (до 62 дней) или с `&range=semester` — весь текущий семестр от первого до последнего дня; приложение хранит его целиком и работает с ним без сети. Ответ содержит дни с неделями, номерами пар и пометкой `uncertain` для нераспознанных пометок, плюс строки Word (`weekly`) и подгруппы. Поэтому правила белых и зелёных недель в Kotlin не дублируются.
Даты считает сервер: `GET /api/timetable?doc=<id>&group=<группа>&from=ГГГГ-ММ-ДД&days=N` (до 62 дней) или с `&range=semester` — весь текущий семестр от первого до последнего дня; приложение хранит его целиком и работает с ним без сети. Ответ содержит дни с неделями, номерами пар и пометкой `uncertain` для нераспознанных пометок, плюс строки Word (`weekly`) и подгруппы. С API 14 у занятия есть `place` — город, если документ его называет, а у дня с парами — `places`: заметки «Занятия в Полоцке» (`{place, text, subgroup}`, `subgroup: null` — для всей группы; одинаковая заметка во всех подгруппах сводится к одной). Поэтому правила белых и зелёных недель в Kotlin не дублируются.
Сборка (JDK 17, Android SDK 37):
@@ -204,6 +334,7 @@ cd android
- **Онбординг:** приветствие (три стеклянные «капли» сливаются в одну), затем группа, подгруппа (только если группа делится на подгруппы) и напоминания. Разрешение на уведомления спрашивается на последнем шаге.
- **Стеклянная панель вкладок:** три раздела — «Обзор», «Расписание», «Планы» — и поиск отдельной круглой кнопкой справа (роль `.search`). Нажатие превращает кнопку в поле ввода над клавиатурой, как в приложениях Apple (`tabViewSearchActivation(.searchTabSelection)`). «Календарь» открывается кнопкой в «Планах», «Предметы» — карточкой в «Обзоре». На iPad панель превращается в боковое меню. Группа и настройки открываются из панели «Обзора»; тема меняется сразу во всех окнах, включая открытые настройки.
- **Занятия в другом городе:** как в Android и PWA. Над парами дня, в «Календаре» и в «Обзоре» (сегодня и ближайший день) стоит карточка «Занятия в Полоцке», в недельной сетке под датой — метка «Полоцк». В карточках пар город стоит рядом с аудиторией, в подробностях — строка «Город»; так же — в виджетах и напоминаниях.
- **Обзор:** неделя (сколько пар осталось, чип на каждый день), обратный отсчёт до сессии или конца семестра, следующие занятия с отсчётом «Через 20 мин», посещаемость одним касанием, задания и ближайший учебный день. На iPad рядом показывается недельная сетка.
- **Изменения в расписании:** как в Android и PWA — карточка «Расписание обновлено · N изменений» в «Обзоре» после перезаливки документа, по «Показать» — лист с изменёнными, добавленными и удалёнными занятиями; последний список — в настройках. Сравнение срабатывает и при фоновом обновлении `BGAppRefreshTask`: тогда приходит локальное уведомление (настройка «Изменения в расписании»). Когда запускать фоновое обновление, решает iOS, поэтому уведомление может прийти через несколько часов, а если приложение смахнули из переключателя — только после следующего открытия.
- **Расписание:** дни листаются в сторону под плавающей стеклянной лентой недели. На зелёных неделях стекло окрашено зелёным. Есть недельная сетка по часам и строки Word, если семестр не найден. В подробностях пары: время по звонкам, посещаемость, задания, исходная запись Word и скрытие таких занятий.
@@ -242,6 +373,11 @@ SIMCTL_CHILD_PSU_API_URL=http://localhost:3010/ xcrun simctl launch booted org.x
- `server/index.js` — API и выдача собранной PWA.
- `server/limits.js` — ограничения импорта: число одновременных разборов и запросов с одного адреса.
- `server/errors.js` — коды ошибок API и их HTTP-статусы.
- `android/app/src/main/kotlin/org/xtimms/psu/schedule/` — приложение Android:
- `data/` — API, модели, настройки, задания и посещаемость; `notify/`, `widget/`, `work/`, `tile/` — уведомления, виджеты, фоновая синхронизация, плитка;
- `ui/` — `MainViewModel` и меню; `ui/components/` — общие элементы (заголовки, лента дней, скелетоны, отмена, свайпы, сгиб экрана), `ui/motion/` — переходы и container transform, `ui/theme/` — тема и её смена;
- `ui/lesson/` — карточка и страница пары, слоты дня, цвета предметов, отметки сессии, города, посещаемость; `ui/notes/` — задания, заметки и фото;
- экраны: `ui/home/` (обзор, изменения, сессия), `ui/schedule/` (дни, неделя, подставка), `ui/plans/` (планы, календарь), `ui/subjects/`, `ui/groups/`, `ui/search/`, `ui/settings/`, `ui/onboarding/`.
- `ios/Shared/` — модели, даты в часовом поясе Минска, расчёт пар и общее хранилище; общие для приложения и виджетов.
- `ios/PSUSchedule/` — приложение iOS: `AppModel.swift` (состояние и загрузка), `API.swift`, `Reminders.swift`, экраны в `Views/`.
- `ios/Widgets/` — виджеты WidgetKit.

View File

@@ -19,8 +19,8 @@ android {
applicationId = "org.xtimms.psu.schedule"
minSdk = 26
targetSdk = 37
versionCode = 1
versionName = "0.1.0"
versionCode = 2
versionName = "0.1.1"
buildConfigField("String", "API_URL", "\"${apiUrl.get()}\"")
}
@@ -59,6 +59,7 @@ dependencies {
implementation(libs.lifecycle.viewmodel.compose)
implementation(libs.lifecycle.runtime.compose)
implementation(libs.core.ktx)
implementation(libs.splashscreen)
implementation(libs.coroutines.android)
implementation(libs.serialization.json)
implementation(libs.okhttp)

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,8 @@
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- Optional: the student can allow exact reminder times in system settings. -->
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<!-- The lesson under way as a Live Update on Android 16 (a promoted ongoing notification). -->
<uses-permission android:name="android.permission.POST_PROMOTED_NOTIFICATIONS" />
<application
android:name=".ScheduleApp"
@@ -20,11 +22,17 @@
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:theme="@style/Theme.PsuSchedule.Starting"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Long press on the icon: today, the week, room search, a new task. -->
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity>
<receiver
@@ -51,6 +59,18 @@
android:resource="@xml/next_widget_info" />
</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
android:name=".tile.LessonTileService"
android:exported="true"
@@ -62,10 +82,29 @@
</intent-filter>
</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
android:name=".notify.ReminderReceiver"
android:exported="false" />
<receiver
android:name=".notify.DayAlertReceiver"
android:exported="false" />
<receiver
android:name=".notify.LiveLessonReceiver"
android:exported="false" />
<receiver
android:name=".notify.BootReceiver"
android:exported="true">

View File

@@ -0,0 +1,53 @@
package org.xtimms.psu.schedule
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import java.time.LocalDate
/**
* Where the app opens from outside: a launcher shortcut (res/xml/shortcuts.xml) or a
* notification. The intent carries a target and, for a day, its date.
*/
sealed interface Launch {
data object Today : Launch
data object Week : Launch
data object Search : Launch
data object NewTask : Launch
data class Day(val date: LocalDate) : Launch
companion object {
const val ACTION = "org.xtimms.psu.schedule.OPEN"
private const val TARGET = "target"
private const val DATE = "date"
fun of(intent: Intent?): Launch? {
if (intent?.action != ACTION) return null
return when (intent.getStringExtra(TARGET)) {
"today" -> Today
"week" -> Week
"search" -> Search
"task" -> NewTask
"day" -> intent.getStringExtra(DATE)?.let { runCatching { Day(LocalDate.parse(it)) }.getOrNull() }
else -> null
}
}
/** 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. */
fun day(context: Context, date: LocalDate, request: Int): PendingIntent =
PendingIntent.getActivity(
context,
request,
intent(context, "day", date),
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 kotlin.coroutines.cancellation.CancellationException
import androidx.compose.runtime.LaunchedEffect
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import org.xtimms.psu.schedule.ui.components.UndoArea
import org.xtimms.psu.schedule.ui.motion.LocalMorph
import org.xtimms.psu.schedule.ui.motion.MorphHost
import org.xtimms.psu.schedule.ui.motion.MorphOverlay
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.rememberTransition
import androidx.compose.animation.core.SeekableTransitionState
@@ -15,8 +20,8 @@ import androidx.compose.material.icons.outlined.CalendarViewWeek
import androidx.compose.material.icons.outlined.Checklist
import androidx.compose.material.icons.rounded.CalendarViewWeek
import androidx.compose.material.icons.rounded.Checklist
import org.xtimms.psu.schedule.ui.CalendarScreen
import org.xtimms.psu.schedule.ui.PlansScreen
import org.xtimms.psu.schedule.ui.plans.CalendarScreen
import org.xtimms.psu.schedule.ui.plans.PlansScreen
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.rememberScrollState
@@ -34,13 +39,13 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.height
import androidx.compose.material.icons.automirrored.rounded.MenuOpen
import org.xtimms.psu.schedule.ui.materialFadeThrough
import org.xtimms.psu.schedule.ui.Emphasized
import org.xtimms.psu.schedule.ui.MotionDuration
import org.xtimms.psu.schedule.ui.motion.materialFadeThrough
import org.xtimms.psu.schedule.ui.motion.Emphasized
import org.xtimms.psu.schedule.ui.motion.MotionDuration
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.animation.core.tween
import org.xtimms.psu.schedule.ui.materialSharedAxisX
import org.xtimms.psu.schedule.ui.rememberSlideDistance
import org.xtimms.psu.schedule.ui.motion.materialSharedAxisX
import org.xtimms.psu.schedule.ui.motion.rememberSlideDistance
import android.graphics.Color
import android.os.Bundle
import androidx.activity.ComponentActivity
@@ -55,10 +60,10 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import org.xtimms.psu.schedule.ui.LocalHinge
import org.xtimms.psu.schedule.ui.EmphasizedDecelerate
import org.xtimms.psu.schedule.ui.EmphasizedAccelerate
import org.xtimms.psu.schedule.ui.LocalFoldTablet
import org.xtimms.psu.schedule.ui.components.LocalHinge
import org.xtimms.psu.schedule.ui.motion.EmphasizedDecelerate
import org.xtimms.psu.schedule.ui.motion.EmphasizedAccelerate
import org.xtimms.psu.schedule.ui.components.LocalFoldTablet
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row
@@ -110,27 +115,31 @@ import kotlinx.coroutines.launch
import org.xtimms.psu.schedule.data.ThemeMode
import org.xtimms.psu.schedule.data.today
import org.xtimms.psu.schedule.ui.AppNav
import org.xtimms.psu.schedule.ui.GroupsScreen
import org.xtimms.psu.schedule.ui.HomeScreen
import org.xtimms.psu.schedule.ui.Onboarding
import org.xtimms.psu.schedule.ui.LocalDisplay
import org.xtimms.psu.schedule.ui.LocalMarks
import org.xtimms.psu.schedule.ui.groups.GroupsScreen
import org.xtimms.psu.schedule.ui.home.HomeScreen
import org.xtimms.psu.schedule.ui.onboarding.Onboarding
import org.xtimms.psu.schedule.ui.lesson.LocalDisplay
import org.xtimms.psu.schedule.ui.lesson.LocalMarks
import org.xtimms.psu.schedule.ui.LocalMenuProgress
import org.xtimms.psu.schedule.ui.theme.card
import androidx.compose.foundation.layout.widthIn
import org.xtimms.psu.schedule.ui.LocalNotes
import org.xtimms.psu.schedule.ui.LocalSubjectSlots
import org.xtimms.psu.schedule.ui.notes.LocalNotes
import org.xtimms.psu.schedule.ui.lesson.LocalSubjectSlots
import org.xtimms.psu.schedule.data.Display
import org.xtimms.psu.schedule.ui.MainViewModel
import org.xtimms.psu.schedule.ui.ScheduleScreen
import org.xtimms.psu.schedule.ui.SearchScreen
import org.xtimms.psu.schedule.ui.SettingsScreen
import org.xtimms.psu.schedule.ui.SubjectsScreen
import org.xtimms.psu.schedule.ui.subjectSlots
import org.xtimms.psu.schedule.ui.schedule.ScheduleScreen
import org.xtimms.psu.schedule.ui.search.SearchScreen
import org.xtimms.psu.schedule.ui.settings.SettingsScreen
import org.xtimms.psu.schedule.ui.subjects.SubjectsScreen
import org.xtimms.psu.schedule.ui.lesson.subjectSlots
import org.xtimms.psu.schedule.ui.theme.ScheduleTheme
import org.xtimms.psu.schedule.ui.theme.ThemeRevealHost
import org.xtimms.psu.schedule.ui.lesson.LessonActionsHost
import org.xtimms.psu.schedule.ui.theme.isDark
import org.xtimms.psu.schedule.ui.theme.page
import java.time.LocalDate
import org.xtimms.psu.schedule.ui.components.LocalSwipeHint
import org.xtimms.psu.schedule.ui.components.SwipeHint
private enum class Tab(val label: String, val icon: ImageVector, val selectedIcon: ImageVector) {
Home("Обзор", Icons.Outlined.Home, Icons.Rounded.Home),
@@ -150,9 +159,30 @@ private val DARK_SCRIM = Color.argb(0x80, 0x1b, 0x1b, 0x1b)
class MainActivity : ComponentActivity() {
private val vm: MainViewModel by viewModels()
/** A launcher shortcut or a notification asked for a screen (see Launch.kt). */
private val launch = androidx.compose.runtime.mutableStateOf<Launch?>(null)
override fun onNewIntent(intent: android.content.Intent) {
super.onNewIntent(intent)
setIntent(intent)
Launch.of(intent)?.let { launch.value = it }
}
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)
// A recreated activity (rotation) must not open the same screen again.
if (savedInstanceState == null) launch.value = Launch.of(intent)
enableEdgeToEdge()
setContent {
val settings by vm.settings.collectAsStateWithLifecycle()
@@ -167,9 +197,11 @@ class MainActivity : ComponentActivity() {
onDispose {}
}
// The fold of a foldable phone, for screens that split their panes around it.
val hinge = org.xtimms.psu.schedule.ui.rememberHinge(this)
val hinge = org.xtimms.psu.schedule.ui.components.rememberHinge(this)
ScheduleTheme(mode = s?.theme ?: ThemeMode.System, dynamicColor = s?.dynamicColor ?: false) {
androidx.compose.runtime.CompositionLocalProvider(org.xtimms.psu.schedule.ui.LocalHinge provides hinge) {
// Light and dark swap with a circle spreading from the finger (ThemeReveal.kt).
ThemeRevealHost(key = dark to (s?.dynamicColor ?: false)) {
androidx.compose.runtime.CompositionLocalProvider(org.xtimms.psu.schedule.ui.components.LocalHinge provides hinge) {
Surface(color = MaterialTheme.colorScheme.page, modifier = Modifier.fillMaxSize()) {
// Wait for the stored settings so a returning student never sees the onboarding.
if (s != null) {
@@ -181,11 +213,18 @@ class MainActivity : ComponentActivity() {
label = "onboarding",
) { first ->
if (first) Onboarding(vm, onDone = { onboarding = false })
else App(vm, firstRun = s.current == null)
else {
// Until the first swipe, a task row shows how it is done (SwipeActions.kt).
val hint = remember(s.swipeTried) { SwipeHint(!s.swipeTried, vm::swipeTried) }
androidx.compose.runtime.CompositionLocalProvider(LocalSwipeHint provides hint) {
App(vm, firstRun = s.current == null, launch = launch.value, onLaunched = { launch.value = null })
}
}
}
}
}
}
}
}
}
}
@@ -199,7 +238,7 @@ private val EXPANDED_WIDTH = 840.dp
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun App(vm: MainViewModel, firstRun: Boolean) {
private fun App(vm: MainViewModel, firstRun: Boolean, launch: Launch?, onLaunched: () -> Unit) {
var tab by rememberSaveable { mutableStateOf(Tab.Home) }
var page by rememberSaveable { mutableStateOf(if (firstRun) Page.Groups else null) }
// Unset until the student picks: wide windows open the schedule as a week grid.
@@ -251,6 +290,12 @@ private fun App(vm: MainViewModel, firstRun: Boolean) {
val slide = rememberSlideDistance()
// 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) {
// A long press on a lesson anywhere opens its actions (LessonActions.kt).
LessonActionsHost(vm) {
BoxWithConstraints(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.card)) {
val wide = maxWidth >= MEDIUM_WIDTH
// An open book-style foldable gets the tablet layout; a flip phone keeps the phone one.
@@ -273,6 +318,23 @@ private fun App(vm: MainViewModel, firstRun: Boolean) {
openMenu = if (wide) null else ({ menuOpen = true }),
)
}
// A shortcut or a notification: its screen, once the student has a group.
LaunchedEffect(launch, hasGroup) {
if (launch == null || !hasGroup) return@LaunchedEffect
page = null
menuOpen = false
when (launch) {
Launch.Today -> nav.openDay(today())
Launch.Week -> nav.openWeek()
Launch.Search -> nav.openSearch()
Launch.NewTask -> {
nav.openPlans()
vm.newTask.value = true
}
is Launch.Day -> nav.openDay(launch.date)
}
onLaunched()
}
val menuItems = MenuItems(
tab = tab,
page = page,
@@ -488,6 +550,11 @@ private fun App(vm: MainViewModel, firstRun: Boolean) {
}
}
}
}
// 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. */

View File

@@ -1,6 +1,7 @@
package org.xtimms.psu.schedule
import android.app.Application
import kotlinx.coroutines.launch
import org.xtimms.psu.schedule.data.Api
import org.xtimms.psu.schedule.data.AttendanceStore
import org.xtimms.psu.schedule.data.NotesStore
@@ -27,7 +28,11 @@ class ScheduleApp : Application() {
attendance = AttendanceStore(this)
Reminders.createChannel(this)
org.xtimms.psu.schedule.notify.ChangeAlerts.createChannel(this)
org.xtimms.psu.schedule.notify.DayAlerts.createChannel(this)
org.xtimms.psu.schedule.notify.LiveLesson.createChannel(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

@@ -68,6 +68,7 @@ private fun fields(a: ChangedLesson, b: ChangedLesson): List<FieldChange> {
("Вид занятия" to (a.lesson.type to b.lesson.type)),
("Преподаватель" to (a.lesson.teachers.joinToString(", ") to b.lesson.teachers.joinToString(", "))),
("Аудитория" to (a.lesson.rooms.joinToString(", ") to b.lesson.rooms.joinToString(", "))),
("Город" to (a.lesson.place.orEmpty() to b.lesson.place.orEmpty())),
).filter { (_, values) -> values.first.trim() != values.second.trim() }
.map { (label, values) -> FieldChange(label, shown(values.first), shown(values.second)) }
}

View File

@@ -12,6 +12,10 @@ data class Timetable(
val semester: Semester? = null,
val note: String? = null,
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 from: String,
val days: List<Day> = emptyList(),
@@ -40,10 +44,38 @@ data class Week(val n: Int, val color: String) {
}
@Serializable
data class Day(val date: String, val weekday: Int, val week: Week? = null, val lessons: List<Lesson> = emptyList())
data class Day(
val date: String,
val weekday: Int,
val week: Week? = null,
val lessons: List<Lesson> = emptyList(),
val places: List<DayPlace> = emptyList(),
)
@Serializable
data class WeeklyDay(val day: Int, val lessons: List<Lesson> = emptyList())
data class WeeklyDay(val day: Int, val lessons: List<Lesson> = emptyList(), val places: List<DayPlace> = emptyList())
/**
* "Занятия в Полоцке" over a day in Word: that day the group's lessons (or one subgroup's,
* when `subgroup` is set) are in another town (server/timetable.js placesOn).
*/
@Serializable
data class DayPlace(
val place: String,
val text: String,
val subgroup: String? = null,
/** "13:40": the note stands in the middle of the day and holds from that lesson on. */
val from: String? = null,
) {
/** "С 13:40 — занятия в Полоцке", or the note as it is when it holds all day. */
val label: String get() = from?.let { "С $it — " + text.replaceFirstChar { c -> c.lowercase() } } ?: text
}
/** The notes for the student's subgroup; a note for their own subgroup reads as the whole day's. */
fun List<DayPlace>.forSubgroup(subgroup: String?): List<DayPlace> =
filter { subgroup == null || it.subgroup == null || it.subgroup == subgroup }
.map { if (subgroup != null) it.copy(subgroup = null) else it }
.distinct()
@Serializable
data class Lesson(
@@ -56,10 +88,16 @@ data class Lesson(
val teachers: List<String> = emptyList(),
val rooms: List<String> = emptyList(),
val roomMoved: Boolean = false,
/** The town, when the cell or the day's note names one: "Полоцк". */
val place: String? = null,
val distance: Boolean = false,
val condition: String = "",
val variant: String = "all",
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 uncertain: Boolean = false,
val raw: String = "",
@@ -68,6 +106,28 @@ data class Lesson(
/** 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()
/** 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. */
fun withPlace(room: String?, place: String?): String? =
listOfNotNull(room?.takeIf { it.isNotEmpty() }, place).joinToString(" · ").ifEmpty { null }
@Serializable
data class GroupsResponse(
val results: List<GroupResult> = emptyList(),
@@ -118,6 +178,7 @@ data class DirectoryLesson(
val teachers: List<String> = emptyList(),
val rooms: List<String> = emptyList(),
val moved: Boolean = false,
val place: String? = null,
val distance: Boolean = false,
val groups: List<String> = emptyList(),
val condition: String = "",

View File

@@ -1,6 +1,11 @@
package org.xtimms.psu.schedule.data
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.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -27,13 +32,16 @@ data class Note(
val task: Boolean = true,
val done: Boolean = false,
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}"
/** Notes live only on the device, in one small JSON file. */
class NotesStore(context: Context) {
/** Notes live only on the device, in one small JSON file; their photos in a folder beside it. */
class NotesStore(private val context: Context) {
private val file = File(context.filesDir, "notes.json")
val photos = File(context.filesDir, "photos")
private val lock = Mutex()
private val _notes = MutableStateFlow(read())
val notes: StateFlow<List<Note>> = _notes
@@ -50,10 +58,77 @@ class NotesStore(context: Context) {
}
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
}
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 } }
}

View File

@@ -27,6 +27,13 @@ class Repository(context: Context, val api: Api, private val settings: Settings)
private val _changes = MutableStateFlow(readChanges())
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> =
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,
)
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 "Последние изменения". */
@@ -110,8 +118,18 @@ 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> =
timetable.days.firstOrNull { it.date == date.toString() }?.lessons.orEmpty()
.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? =
timetable.days.firstOrNull { it.date == date.toString() }
/**
* "Занятия в Полоцке" on the date for the student's subgroup, and only on a day they have
* lessons. A note for their own subgroup reads as the whole day's.
*/
fun placesOn(timetable: Timetable, date: LocalDate, subgroup: String?, hidden: Set<String> = emptySet()): List<DayPlace> {
val day = dayOf(timetable, date) ?: return emptyList()
if (day.places.isEmpty() || lessonsOn(timetable, date, subgroup, hidden).isEmpty()) return emptyList()
return day.places.forSubgroup(subgroup)
}
}

View File

@@ -43,10 +43,36 @@ data class UserSettings(
val reminderMinutes: Int = 10,
/** A notification when the background sync finds changes in the group's document. */
val changeAlerts: Boolean = true,
/**
* "Завтра занятия в Полоцке": a notification the evening before a day in another town, at
* [travelTime] (minutes after midnight), and the reminder of that day's first lesson
* [travelLead] minutes earlier than usual.
*/
val travelAlerts: Boolean = true,
val travelTime: Int = 20 * 60,
val travelLead: Int = 30,
/** A notification on the morning of a day with a room move, at [roomTime]. */
val roomAlerts: Boolean = true,
val roomTime: Int = 7 * 60,
/** The lesson under way as an ongoing notification (a Live Update on Android 16). */
val liveLesson: Boolean = false,
/** The student has swiped a task once: the rows stop showing how. */
val swipeTried: Boolean = false,
/** Teachers and rooms starred in the search, as "teacher:Ковшик О.С." or "room:206Г". */
val searchFavorites: List<String> = emptyList(),
/** The teachers and rooms opened in the search lately, newest first. */
val searchRecent: List<String> = emptyList(),
val recent: List<SavedGroup> = emptyList(),
val display: Display = Display(),
/** 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(),
/** 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(),
)
@@ -57,11 +83,22 @@ class Settings(private val context: Context) {
private object Keys {
val current = stringPreferencesKey("current")
val subgroups = stringPreferencesKey("subgroups")
val choices = stringPreferencesKey("choices")
val foreign = stringPreferencesKey("foreign")
val theme = stringPreferencesKey("theme")
val dynamic = booleanPreferencesKey("dynamic_color")
val reminders = booleanPreferencesKey("reminders")
val minutes = intPreferencesKey("reminder_minutes")
val changeAlerts = booleanPreferencesKey("change_alerts")
val travelAlerts = booleanPreferencesKey("travel_alerts")
val travelTime = intPreferencesKey("travel_time")
val travelLead = intPreferencesKey("travel_lead")
val roomAlerts = booleanPreferencesKey("room_alerts")
val roomTime = intPreferencesKey("room_time")
val liveLesson = booleanPreferencesKey("live_lesson")
val swipeTried = booleanPreferencesKey("swipe_tried")
val searchFavorites = stringPreferencesKey("search_favorites")
val searchRecent = stringPreferencesKey("search_recent")
val recent = stringPreferencesKey("recent")
val colors = booleanPreferencesKey("show_colors")
val teachers = booleanPreferencesKey("show_teachers")
@@ -75,6 +112,15 @@ class Settings(private val context: Context) {
val plansDone = booleanPreferencesKey("plans_done")
}
private fun Preferences.list(key: Preferences.Key<String>): List<String> =
this[key]?.let { runCatching { json.decodeFromString<List<String>>(it) }.getOrNull() }.orEmpty()
private fun Preferences.choiceMap(): Map<String, Map<String, String>> =
this[Keys.choices]?.let { runCatching { json.decodeFromString<Map<String, Map<String, String>>>(it) }.getOrNull() }.orEmpty()
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>> =
this[Keys.hidden]?.let { runCatching { json.decodeFromString<Map<String, List<String>>>(it) }.getOrNull() }.orEmpty()
@@ -87,6 +133,14 @@ class Settings(private val context: Context) {
val subgroups = this[Keys.subgroups]?.let {
runCatching { json.decodeFromString<Map<String, String>>(it) }.getOrNull()
}.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(
current = current,
subgroup = current?.let { subgroups[subgroupKey(it)] },
@@ -95,10 +149,21 @@ class Settings(private val context: Context) {
reminders = this[Keys.reminders] ?: false,
reminderMinutes = this[Keys.minutes] ?: 10,
changeAlerts = this[Keys.changeAlerts] ?: true,
travelAlerts = this[Keys.travelAlerts] ?: true,
travelTime = this[Keys.travelTime] ?: 20 * 60,
travelLead = this[Keys.travelLead] ?: 30,
roomAlerts = this[Keys.roomAlerts] ?: true,
roomTime = this[Keys.roomTime] ?: 7 * 60,
liveLesson = this[Keys.liveLesson] ?: false,
swipeTried = this[Keys.swipeTried] ?: false,
searchFavorites = list(Keys.searchFavorites),
searchRecent = list(Keys.searchRecent),
recent = this[Keys.recent]?.let {
runCatching { json.decodeFromString<List<SavedGroup>>(it) }.getOrNull()
}.orEmpty(),
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(
layout = this[Keys.plansLayout]?.let { runCatching { PlansLayout.valueOf(it) }.getOrNull() } ?: PlansLayout.Agenda,
lessons = this[Keys.plansLessons] ?: false,
@@ -117,6 +182,10 @@ class Settings(private val context: Context) {
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 ->
prefs[Keys.current] = json.encodeToString(group)
val recent = prefs[Keys.recent]?.let {
@@ -126,6 +195,14 @@ class Settings(private val context: Context) {
prefs[Keys.recent] = json.encodeToString(next)
}
/** Drops a group the server no longer knows from the quick-switch list. */
suspend fun forgetRecent(group: SavedGroup) = context.store.edit { prefs ->
val recent = prefs[Keys.recent]?.let {
runCatching { json.decodeFromString<List<SavedGroup>>(it) }.getOrNull()
}.orEmpty()
prefs[Keys.recent] = json.encodeToString(recent.filterNot { it.docId == group.docId && it.group == group.group })
}
suspend fun setSubgroup(label: String?) = context.store.edit { prefs ->
val current = prefs[Keys.current]?.let { runCatching { json.decodeFromString<SavedGroup>(it) }.getOrNull() }
?: return@edit
@@ -136,11 +213,49 @@ class Settings(private val context: Context) {
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 setDynamicColor(on: Boolean) = context.store.edit { it[Keys.dynamic] = on }
suspend fun setReminders(on: Boolean) = context.store.edit { it[Keys.reminders] = on }
suspend fun setReminderMinutes(minutes: Int) = context.store.edit { it[Keys.minutes] = minutes }
suspend fun setChangeAlerts(on: Boolean) = context.store.edit { it[Keys.changeAlerts] = on }
suspend fun setTravelAlerts(on: Boolean) = context.store.edit { it[Keys.travelAlerts] = on }
suspend fun setTravelTime(minutes: Int) = context.store.edit { it[Keys.travelTime] = minutes }
suspend fun setTravelLead(minutes: Int) = context.store.edit { it[Keys.travelLead] = minutes }
suspend fun setRoomAlerts(on: Boolean) = context.store.edit { it[Keys.roomAlerts] = on }
suspend fun setRoomTime(minutes: Int) = context.store.edit { it[Keys.roomTime] = minutes }
suspend fun setLiveLesson(on: Boolean) = context.store.edit { it[Keys.liveLesson] = on }
suspend fun swipeTried() = context.store.edit { it[Keys.swipeTried] = true }
/** Stars a teacher or a room of the search, or takes the star back. */
suspend fun toggleSearchFavorite(target: String) = context.store.edit { prefs ->
val list = prefs.list(Keys.searchFavorites)
prefs[Keys.searchFavorites] = json.encodeToString(if (target in list) list - target else list + target)
}
/** Remembers a teacher or a room opened in the search; the latest eight stay. */
suspend fun rememberSearch(target: String) = context.store.edit { prefs ->
prefs[Keys.searchRecent] = json.encodeToString((listOf(target) + prefs.list(Keys.searchRecent).filter { it != target }).take(8))
}
/** Hides or shows again a recurring lesson of the open group. */
suspend fun setHidden(key: String, hidden: Boolean) = context.store.edit { prefs ->

View File

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

View File

@@ -0,0 +1,144 @@
package org.xtimms.psu.schedule.notify
import android.app.AlarmManager
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import org.xtimms.psu.schedule.Launch
import org.xtimms.psu.schedule.R
import org.xtimms.psu.schedule.data.Lesson
import org.xtimms.psu.schedule.data.MINSK
import org.xtimms.psu.schedule.data.Repository
import org.xtimms.psu.schedule.data.Timetable
import org.xtimms.psu.schedule.data.UserSettings
import org.xtimms.psu.schedule.data.parseDate
import org.xtimms.psu.schedule.data.parseTime
import org.xtimms.psu.schedule.data.plural
import org.xtimms.psu.schedule.data.today
import org.xtimms.psu.schedule.data.withPlace
import java.time.Instant
import java.time.LocalDate
import java.time.LocalTime
/**
* What the day has in store, told in advance, from the cached timetable like the reminders:
* "Завтра занятия в Полоцке" the evening before a day in another town, and the morning's room
* moves ("10:05 Типология — ауд. 333, обычно 412Н"). Each has its switch and time in settings.
*/
object DayAlerts {
private const val CHANNEL = "day_alerts"
private const val ACTION = "org.xtimms.psu.schedule.DAY_ALERT"
// Request codes: a week of travel evenings, then a week of room-move mornings.
private const val TRAVEL = 0
private const val ROOMS = 16
private const val MAX = 16
fun createChannel(context: Context) {
val channel = NotificationChannel(
CHANNEL,
context.getString(R.string.channel_day),
NotificationManager.IMPORTANCE_DEFAULT,
).apply { description = "Накануне дня в другом городе и утром в день переноса аудитории" }
context.getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
}
private fun intent(context: Context, code: Int, extras: Intent.() -> Unit = {}): PendingIntent =
PendingIntent.getBroadcast(
context,
code,
Intent(context, DayAlertReceiver::class.java).setAction(ACTION).apply(extras),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
private fun rooms(l: Lesson) = l.rooms.joinToString(", ").takeIf { it.isNotEmpty() }?.let { "ауд. $it" }
/** Replaces the scheduled alerts with the ones of the coming week. */
fun reschedule(context: Context, timetable: Timetable?, settings: UserSettings, repository: Repository) {
val alarms = context.getSystemService(AlarmManager::class.java)
repeat(ROOMS + MAX) { alarms.cancel(intent(context, it)) }
if (timetable == null) return
val now = Instant.now()
val days = (0..7L).map { today().plusDays(it) }
var travel = 0
var moves = 0
for (date in days) {
val lessons = repository.lessonsOn(timetable, date, settings.subgroup, settings.hidden).sortedBy { it.start }
val first = lessons.firstOrNull() ?: continue
val places = repository.placesOn(timetable, date, settings.subgroup, settings.hidden)
if (settings.travelAlerts && places.isNotEmpty() && travel < MAX) {
val at = date.minusDays(1).atTime(LocalTime.ofSecondOfDay(settings.travelTime * 60L)).atZone(MINSK).toInstant()
if (at.isAfter(now)) {
val note = places.first()
// "Завтра с 13:40 — занятия в Полоцке" when the town is only for the afternoon.
val title = "Завтра " + note.label.replaceFirstChar { it.lowercase() } + (note.subgroup?.let { " · ${it.lowercase()}" } ?: "")
val text = "Первая пара в ${first.start} — ${first.subject}" +
(withPlace(rooms(first), first.place)?.let { ", $it" } ?: "") +
". Всего ${lessons.distinctBy { it.start }.size} ${plural(lessons.distinctBy { it.start }.size, "пара", "пары", "пар")}."
Reminders.alarm(context, at.toEpochMilli(), intent(context, TRAVEL + travel) {
putExtra("id", TRAVEL + travel)
putExtra("title", title)
putExtra("text", text)
putExtra("date", date.toString())
})
travel++
}
}
val moved = lessons.filter { it.roomMoved }
if (settings.roomAlerts && moved.isNotEmpty() && moves < MAX) {
// The chosen morning time, or an hour before the first moved lesson if that is earlier.
val firstMoved = date.atTime(parseTime(moved.first().start))
val morning = date.atTime(LocalTime.ofSecondOfDay(settings.roomTime * 60L))
val at = minOf(morning, firstMoved.minusHours(1)).atZone(MINSK).toInstant()
if (at.isAfter(now)) {
// The usual room comes from the Word row of the weekday: same lesson key.
val usual = timetable.weekly.flatMap { it.lessons }.associateBy { it.key }
val lines = moved.map { l ->
val before = usual[l.key]?.rooms?.joinToString(", ")?.takeIf { it.isNotEmpty() && it != l.rooms.joinToString(", ") }
"${l.start} ${l.subject} — ${rooms(l) ?: "аудитория не указана"}" + (before?.let { ", обычно $it" } ?: "")
}
// It comes on the day itself.
val title = if (moved.size == 1) "Сегодня перенос аудитории" else "Сегодня переносов аудиторий: ${moved.size}"
Reminders.alarm(context, at.toEpochMilli(), intent(context, ROOMS + moves) {
putExtra("id", ROOMS + moves)
putExtra("title", title)
putExtra("text", lines.joinToString("\n"))
putExtra("date", date.toString())
})
moves++
}
}
}
}
fun show(context: Context, id: Int, title: String, text: String, date: LocalDate) {
if (!Reminders.canNotify(context)) return
val notification = NotificationCompat.Builder(context, CHANNEL)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setContentText(text.lineSequence().first())
.setStyle(NotificationCompat.BigTextStyle().bigText(text))
.setCategory(NotificationCompat.CATEGORY_REMINDER)
.setContentIntent(Launch.day(context, date, 2000 + id))
.setAutoCancel(true)
.build()
@Suppress("MissingPermission")
NotificationManagerCompat.from(context).notify(2000 + id, notification)
}
}
class DayAlertReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
DayAlerts.show(
context,
intent.getIntExtra("id", 0),
intent.getStringExtra("title") ?: return,
intent.getStringExtra("text").orEmpty(),
intent.getStringExtra("date")?.let(::parseDate) ?: today(),
)
}
}

View File

@@ -0,0 +1,164 @@
package org.xtimms.psu.schedule.notify
import android.app.AlarmManager
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.xtimms.psu.schedule.Launch
import org.xtimms.psu.schedule.R
import org.xtimms.psu.schedule.app
import org.xtimms.psu.schedule.data.Lesson
import org.xtimms.psu.schedule.data.MINSK
import org.xtimms.psu.schedule.data.Repository
import org.xtimms.psu.schedule.data.Timetable
import org.xtimms.psu.schedule.data.UserSettings
import org.xtimms.psu.schedule.data.nowDateTime
import org.xtimms.psu.schedule.data.parseTime
import org.xtimms.psu.schedule.data.today
import org.xtimms.psu.schedule.data.withPlace
import org.xtimms.psu.schedule.ui.lesson.lessonEnd
import java.time.Duration
import java.time.LocalDate
import java.time.LocalDateTime
/**
* The lesson under way as an ongoing notification: the subject, the room, how long is left
* and the next lesson. On Android 16 it is a Live Update (a promoted ongoing notification with
* a progress bar in the pair's two halves) in the status bar and on the lock screen. An alarm
* at each lesson's start shows it; while the lesson runs it refreshes every few minutes and it
* leaves by itself at the end. Off by default ("Текущая пара в шторке").
*/
object LiveLesson {
private const val CHANNEL = "lesson_now"
private const val ID = 3000
private const val ACTION = "org.xtimms.psu.schedule.LIVE_LESSON"
private const val MAX = 48
// The refresh while a lesson runs, after the start alarms' request codes.
private const val TICK = MAX
private const val EVERY = 5 * 60 * 1000L
fun createChannel(context: Context) {
val channel = NotificationChannel(
CHANNEL,
context.getString(R.string.channel_now),
NotificationManager.IMPORTANCE_DEFAULT,
).apply {
description = "Идущая пара: сколько осталось и что дальше"
setSound(null, null)
enableVibration(false)
}
context.getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
}
private fun intent(context: Context, code: Int): PendingIntent =
PendingIntent.getBroadcast(
context,
code,
Intent(context, LiveLessonReceiver::class.java).setAction(ACTION),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
private class Slot(val start: LocalDateTime, val end: LocalDateTime, val lessons: List<Lesson>)
private fun slots(t: Timetable, date: LocalDate, s: UserSettings, repository: Repository) =
repository.lessonsOn(t, date, s.subgroup, s.hidden)
.groupBy { it.start }
.map { (start, lessons) -> Slot(date.atTime(parseTime(start)), date.atTime(lessonEnd(lessons.first(), t)), lessons) }
.sortedBy { it.start }
/** Alarms at the starts of the coming lessons, and the lesson under way shown now. */
fun reschedule(context: Context, t: Timetable?, s: UserSettings, repository: Repository) {
val alarms = context.getSystemService(AlarmManager::class.java)
repeat(MAX + 1) { alarms.cancel(intent(context, it)) }
if (!s.liveLesson || t == null) {
NotificationManagerCompat.from(context).cancel(ID)
return
}
val now = nowDateTime()
var code = 0
for (offset in 0..2L) for (slot in slots(t, today().plusDays(offset), s, repository)) {
if (code >= MAX) break
if (slot.start.isAfter(now)) Reminders.alarm(context, slot.start.atZone(MINSK).toInstant().toEpochMilli(), intent(context, code++))
}
update(context, t, s, repository)
}
/** Shows, refreshes or removes the notification for this moment. */
fun update(context: Context, t: Timetable?, s: UserSettings, repository: Repository) {
val manager = NotificationManagerCompat.from(context)
val now = nowDateTime()
val day = if (t != null && s.liveLesson) slots(t, today(), s, repository) else emptyList()
val slot = day.firstOrNull { !now.isBefore(it.start) && now.isBefore(it.end) }
if (slot == null || !Reminders.canNotify(context) || t == null) {
manager.cancel(ID)
return
}
val next = day.firstOrNull { it.start >= slot.end }
val first = slot.lessons.first()
val total = Duration.between(slot.start, slot.end).toMinutes().toInt().coerceAtLeast(1)
val passed = Duration.between(slot.start, now).toMinutes().toInt().coerceIn(0, total)
val room = withPlace(
slot.lessons.flatMap { it.rooms }.distinct().joinToString(", ").takeIf { it.isNotEmpty() }?.let { "ауд. $it" },
slot.lessons.firstNotNullOfOrNull { it.place },
)
// The pair's halves from the bell table ("08:30–09:10, 09:15–09:55"): one segment each.
val halves = t.bells.firstOrNull { it.start == first.start }?.halves.orEmpty()
.mapNotNull { h -> runCatching { Duration.between(slot.start.toLocalTime(), parseTime(h[1])).toMinutes().toInt() }.getOrNull() }
.filter { it in 1 until total }
val progress = NotificationCompat.ProgressStyle()
.setStyledByProgress(true)
.setProgressSegments(
(listOf(0) + halves + total).zipWithNext { a, b -> b - a }
.filter { it > 0 }
.map { NotificationCompat.ProgressStyle.Segment(it) },
)
.setProgress(passed)
val text = listOfNotNull("до ${slot.end.toLocalTime()}", room).joinToString(" · ")
val notification = NotificationCompat.Builder(context, CHANNEL)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(slot.lessons.joinToString(" / ") { it.subject })
.setContentText(text)
.setSubText(next?.let { "Дальше в ${it.start.toLocalTime()} — ${it.lessons.first().subject}" })
.setStyle(progress)
// The status bar chip counts down to the end by itself (a short text would replace it).
.setUsesChronometer(true)
.setChronometerCountDown(true)
.setWhen(slot.end.atZone(MINSK).toInstant().toEpochMilli())
.setOngoing(true)
.setOnlyAlertOnce(true)
.setSilent(true)
.setRequestPromotedOngoing(true)
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
.setTimeoutAfter(Duration.between(now, slot.end).toMillis())
.setContentIntent(Launch.day(context, today(), ID))
.build()
@Suppress("MissingPermission")
manager.notify(ID, notification)
// The next refresh: in a few minutes, or at the end to take the notification away.
val nextTick = minOf(now.plusNanos(EVERY * 1_000_000), slot.end).atZone(MINSK).toInstant().toEpochMilli()
context.getSystemService(AlarmManager::class.java)
.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, nextTick, intent(context, TICK))
}
}
class LiveLessonReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val pending = goAsync()
CoroutineScope(Dispatchers.Default).launch {
try {
val app = context.app
LiveLesson.update(context, app.repository.timetable.value, app.settings.now(), app.repository)
} finally {
pending.finish()
}
}
}
}

View File

@@ -1,6 +1,8 @@
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.forSubgroup
import android.Manifest
import android.app.AlarmManager
import android.app.NotificationChannel
@@ -75,21 +77,28 @@ object Reminders {
var slot = 0
for (day in timetable.days) {
val date = parseDate(day.date)
// One reminder per start time: parallel subgroups share the notification.
for ((start, lessons) in day.lessons
val shown = day.lessons
.filter { settings.subgroup == null || it.subgroup == null || it.subgroup == settings.subgroup }
.filter { it.hideKey !in settings.hidden }
.groupBy { it.start }) {
.filter { !it.hiddenIn(settings.hidden) }
// 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 first = shown.minOfOrNull { it.start }
// One reminder per start time: parallel subgroups share the notification.
for ((start, lessons) in shown.groupBy { it.start }) {
val begins = date.atTime(parseTime(start)).atZone(MINSK).toInstant()
val at = begins.minusSeconds(settings.reminderMinutes * 60L)
val lead = settings.reminderMinutes + if (away && start == first) settings.travelLead else 0
val at = begins.minusSeconds(lead * 60L)
if (at.isBefore(now) || begins.isAfter(horizon) || slot >= MAX) continue
val first = lessons.first()
val title = if (lessons.size == 1) first.subject else lessons.joinToString(" / ") { it.subject }
val place = lessons.flatMap { it.rooms }.distinct().joinToString(", ")
val place = withPlace(
lessons.flatMap { it.rooms }.distinct().joinToString(", ").takeIf { it.isNotEmpty() }?.let { "ауд. $it" },
lessons.firstNotNullOfOrNull { it.place },
)
val text = buildString {
append(start)
first.pair?.let { append(" · $it пара") }
if (place.isNotEmpty()) append(" · ауд. $place")
place?.let { append(" · $it") }
if (lessons.any { it.uncertain }) append(" · сверьте недели")
}
val pending = intent(context, slot) {
@@ -97,18 +106,24 @@ object Reminders {
putExtra("text", text)
putExtra("id", slot)
}
if (canBeExact(context)) {
alarms.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, at.toEpochMilli(), pending)
} else {
// Without exact alarms Android may deliver up to an hour late; a
// 10-minute window (the shortest allowed) centred on the time keeps it useful.
alarms.setWindow(AlarmManager.RTC_WAKEUP, at.toEpochMilli() - WINDOW / 2, WINDOW, pending)
}
alarm(context, at.toEpochMilli(), pending)
slot++
}
}
}
/** An alarm at [at], exact when the student allowed it. */
fun alarm(context: Context, at: Long, pending: PendingIntent) {
val alarms = context.getSystemService(AlarmManager::class.java)
if (canBeExact(context)) {
alarms.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, at, pending)
} else {
// Without exact alarms Android may deliver up to an hour late; a
// 10-minute window (the shortest allowed) centred on the time keeps it useful.
alarms.setWindow(AlarmManager.RTC_WAKEUP, at - WINDOW / 2, WINDOW, pending)
}
}
fun show(context: Context, id: Int, title: String, text: String) {
if (!canNotify(context)) return
val open = PendingIntent.getActivity(
@@ -147,8 +162,8 @@ class BootReceiver : BroadcastReceiver() {
val pending = goAsync()
CoroutineScope(Dispatchers.Default).launch {
try {
val app = context.app
Reminders.reschedule(context, app.repository.timetable.value, app.settings.now())
// Reminders, day alerts and the live lesson, as after any change.
org.xtimms.psu.schedule.work.SyncWorker.afterChange(context)
} finally {
pending.finish()
}

View File

@@ -32,7 +32,7 @@ class LessonTileService : TileService() {
tile.state = Tile.STATE_INACTIVE
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) tile.subtitle = "на ближайшую неделю"
} else {
val room = next.rooms.takeIf { it.isNotEmpty() }?.let { "ауд. $it" }
val room = next.where
tile.label = listOfNotNull(next.whenLabel(), room).joinToString(" · ")
tile.state = if (next.running) Tile.STATE_ACTIVE else Tile.STATE_INACTIVE
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) tile.subtitle = next.subject

View File

@@ -23,8 +23,19 @@ import org.xtimms.psu.schedule.data.ThemeMode
import org.xtimms.psu.schedule.data.Timetable
import org.xtimms.psu.schedule.data.UserSettings
import org.xtimms.psu.schedule.work.SyncWorker
import org.xtimms.psu.schedule.ui.components.UndoHost
import org.xtimms.psu.schedule.data.parseDate
import org.xtimms.psu.schedule.ui.notes.dueLabel
import org.xtimms.psu.schedule.ui.notes.general
import org.xtimms.psu.schedule.ui.notes.nextOccurrence
data class Status(val loading: Boolean = false, val message: String? = null, val offline: Boolean = false)
data class Status(val loading: Boolean = false, val message: String? = null, val offline: Boolean = false, val code: String? = null) {
/**
* The saved group is gone from its document — split into groups ("24-ИЯ(РА)" became
* "24-ИЯ (РА)и-1" …) or the document left the catalog: only picking the group again helps.
*/
val needsGroup get() = code == "group_not_found" || code == "document_not_found"
}
class MainViewModel(application: Application) : AndroidViewModel(application) {
private val app = application.app
@@ -68,9 +79,41 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
}
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) }
/**
* Moves a task or a note to the next lesson of its subject, a plan without one to the next
* day, and offers the old place back in [undo]. With no such lesson loaded it says so.
*/
fun postponeNote(note: Note, undo: UndoHost?) = viewModelScope.launch {
val from = parseDate(note.date)
val moved = if (note.general) note.copy(date = from.plusDays(1).toString())
else {
val t = timetable.value ?: return@launch
val next = nextOccurrence(t, note.subject, from, note.start, settings.value?.subgroup)
?: return@launch undo?.say("Следующей пары «${note.subject}» в расписании нет") ?: Unit
note.copy(date = next.first.toString(), start = next.second.start)
}
app.notes.save(moved)
// "на пн, 26 октября, 15:20": the time too, as the next lesson may be later the same day.
val day = moved.dueLabel().removePrefix("к ")
val at = if (moved.start.isNotBlank() && moved.start !in day) "$day, ${moved.start}" else day
undo?.offer("Перенесено на $at", onUndo = { viewModelScope.launch { app.notes.save(note) } })
}
/** The key new notes are filed under, or null before a group is chosen. */
fun currentGroupKey(): String? = settings.value?.current?.let(::groupKey)
@@ -118,13 +161,17 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
val result = block()
val error = result.exceptionOrNull()
val offline = (error as? ApiException)?.offline == true
val code = (error as? ApiException)?.code
lastFailed = if (error != null) block else null
_status.value = Status(
loading = false,
// Offline with a timetable on the device still leaves something to look at.
message = error?.message?.let { if (offline && timetable.value != null) "$it Показана сохранённая копия." else it },
offline = offline,
code = code,
)
// A group the server no longer has stays out of the quick-switch list.
if (_status.value.needsGroup) app.settings.now().current?.let { app.settings.forgetRecent(it) }
SyncWorker.afterChange(getApplication())
return result.isSuccess
}
@@ -138,6 +185,21 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
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 toggleSearchFavorite(target: String) = viewModelScope.launch { app.settings.toggleSearchFavorite(target) }
fun rememberSearch(target: String) = viewModelScope.launch { app.settings.rememberSearch(target) }
fun swipeTried() = viewModelScope.launch { if (settings.value?.swipeTried == false) app.settings.swipeTried() }
fun setTheme(mode: ThemeMode) = viewModelScope.launch { app.settings.setTheme(mode) }
fun setDynamicColor(on: Boolean) = viewModelScope.launch { app.settings.setDynamicColor(on) }
fun setDisplay(display: Display) = viewModelScope.launch { app.settings.setDisplay(display) }
@@ -156,6 +218,29 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
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. */
val newTask = kotlinx.coroutines.flow.MutableStateFlow(false)
/** Day alerts and the live lesson are alarms too: set them again after every change. */
private fun alerts(change: suspend () -> Unit) = viewModelScope.launch {
change()
SyncWorker.afterChange(getApplication())
}
fun setTravelAlerts(on: Boolean) = alerts { app.settings.setTravelAlerts(on) }
fun setTravelTime(minutes: Int) = alerts { app.settings.setTravelTime(minutes) }
fun setTravelLead(minutes: Int) = alerts { app.settings.setTravelLead(minutes) }
fun setRoomAlerts(on: Boolean) = alerts { app.settings.setRoomAlerts(on) }
fun setRoomTime(minutes: Int) = alerts { app.settings.setRoomTime(minutes) }
fun setLiveLesson(on: Boolean) = alerts { app.settings.setLiveLesson(on) }
fun setReminderMinutes(minutes: Int) = viewModelScope.launch {
app.settings.setReminderMinutes(minutes)
SyncWorker.afterChange(getApplication())

View File

@@ -1,105 +0,0 @@
package org.xtimms.psu.schedule.ui
import androidx.compose.ui.Modifier
import androidx.compose.material3.MaterialTheme
import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridItemScope
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.layout.Box
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.ContentTransform
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import android.graphics.Path
import android.view.animation.PathInterpolator
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.Easing
import androidx.compose.animation.core.FastOutLinearInEasing
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
// Material motion patterns, as in RideBus: shared axis X between a screen and the page it
// opens, fade through between top-level destinations.
/** How far a shared axis transition slides: 30dp in pixels. */
@Composable
fun rememberSlideDistance(): Int {
val density = LocalDensity.current
return remember(density) { with(density) { 30.dp.roundToPx() } }
}
// A predictive back gesture can pause anywhere, so its fades overlap over the whole
// transition instead of handing over in the middle, as RideBus does.
private fun sharedAxisXIn(forward: Boolean, distance: Int, predictive: Boolean): EnterTransition =
slideInHorizontally(tween(300, easing = FastOutSlowInEasing)) { if (forward) distance else -distance } +
fadeIn(
if (predictive) tween(300, easing = LinearEasing)
else tween(durationMillis = 195, delayMillis = 105, easing = LinearOutSlowInEasing),
)
private fun sharedAxisXOut(forward: Boolean, distance: Int, predictive: Boolean): ExitTransition =
slideOutHorizontally(tween(300, easing = FastOutSlowInEasing)) { if (forward) -distance else distance } +
fadeOut(if (predictive) tween(300, easing = LinearEasing) else tween(durationMillis = 105, easing = FastOutLinearInEasing))
/** Forward into a page slides in from the end; back slides the other way. */
fun materialSharedAxisX(forward: Boolean, distance: Int, predictive: Boolean = false): ContentTransform =
sharedAxisXIn(forward, distance, predictive) togetherWith sharedAxisXOut(forward, distance, predictive)
/**
* md.sys.motion.easing.emphasized: the two-segment path M3 uses for large transitions that
* begin and end on screen. A single cubic only approximates it, so this follows the path itself.
*/
val Emphasized: Easing = run {
val path = Path().apply {
moveTo(0f, 0f)
cubicTo(0.05f, 0f, 0.133333f, 0.06f, 0.166666f, 0.4f)
cubicTo(0.208333f, 0.82f, 0.25f, 1f, 1f, 1f)
}
val interpolator = PathInterpolator(path)
Easing { interpolator.getInterpolation(it) }
}
/** md.sys.motion.duration tokens used here, in milliseconds. */
object MotionDuration {
const val Short4 = 200
const val Medium4 = 400
const val Long2 = 500
}
/** M3 emphasized easing for components entering the screen: fast start, long gentle stop. */
val EmphasizedDecelerate = CubicBezierEasing(0.05f, 0.7f, 0.1f, 1f)
/** M3 emphasized easing for components leaving: they speed up and come to rest just off screen. */
val EmphasizedAccelerate = CubicBezierEasing(0.3f, 0f, 0.8f, 0.15f)
/**
* A list item that eases into place: it fades in and out as it appears or leaves, slides
* when items around it change, and grows or shrinks with its content (a card that fills
* up or empties) instead of jumping.
*/
@Composable
fun LazyItemScope.AnimatedItem(content: @Composable () -> Unit) {
Box(Modifier.animateItem().animateContentSize(MaterialTheme.motionScheme.defaultSpatialSpec())) { content() }
}
/** The same for a grid item. */
@Composable
fun LazyStaggeredGridItemScope.AnimatedItem(content: @Composable () -> Unit) {
Box(Modifier.animateItem().animateContentSize(MaterialTheme.motionScheme.defaultSpatialSpec())) { content() }
}
/** Between sibling destinations: the old one fades out, the new one fades and grows in. */
fun materialFadeThrough(): ContentTransform =
(fadeIn(tween(400, easing = EmphasizedDecelerate)) + scaleIn(tween(400, easing = EmphasizedDecelerate), initialScale = 0.92f)) togetherWith
fadeOut(tween(200, easing = EmphasizedAccelerate))

View File

@@ -1,7 +1,9 @@
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
package org.xtimms.psu.schedule.ui
package org.xtimms.psu.schedule.ui.components
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.platform.LocalConfiguration
@@ -21,6 +23,26 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.material3.HorizontalDivider
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.foundation.border
import androidx.compose.ui.graphics.lerp
@@ -94,6 +116,13 @@ import org.xtimms.psu.schedule.data.today
import org.xtimms.psu.schedule.data.weekSpan
import org.xtimms.psu.schedule.ui.theme.WeekColors
import java.time.LocalDate
import org.xtimms.psu.schedule.ui.lesson.SubjectColor
import org.xtimms.psu.schedule.ui.lesson.luminance
import org.xtimms.psu.schedule.ui.motion.rememberReducedMotion
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.BoxWithConstraints
/** A tablet, by its smallest side: its app bars stay calm, as School Planner's do. */
@Composable
@@ -154,47 +183,71 @@ fun Heading(
Text(text, style = style, modifier = modifier.semantics { heading() })
}
/** M3 Expressive connected button group: one choice out of a few. */
/**
* M3 Expressive connected button group: one choice out of a few. The chosen one gets a check
* before its label when both fit; a long label ("Светлая" in a third of a phone) keeps whole
* and the fill alone marks it.
*/
@Composable
fun <T> ConnectedChoice(
options: List<Pair<T, String>>,
selected: T,
onSelect: (T) -> Unit,
modifier: Modifier = Modifier,
/** The check before the chosen label; times like "20:00" need the room, the fill marks it. */
mark: Boolean = true,
) {
Row(
modifier.selectableGroup(),
horizontalArrangement = Arrangement.spacedBy(ButtonGroupDefaults.ConnectedSpaceBetween),
) {
options.forEachIndexed { index, (value, label) ->
val checked = value == selected
ToggleButton(
checked = checked,
onCheckedChange = { onSelect(value) },
modifier = Modifier.weight(1f).semantics { role = Role.RadioButton },
// Unselected segments need their own tonal fill to read as one group.
colors = ToggleButtonDefaults.colors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
checkedContainerColor = MaterialTheme.colorScheme.primary,
checkedContentColor = MaterialTheme.colorScheme.onPrimary,
),
shapes = when (index) {
0 -> ButtonGroupDefaults.connectedLeadingButtonShapes()
options.lastIndex -> ButtonGroupDefaults.connectedTrailingButtonShapes()
else -> ButtonGroupDefaults.connectedMiddleButtonShapes()
},
) {
if (checked) {
Icon(Icons.Rounded.Check, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(6.dp))
val haptic = LocalHapticFeedback.current
val measurer = rememberTextMeasurer()
val style = MaterialTheme.typography.labelLarge
val density = LocalDensity.current
BoxWithConstraints(modifier) {
val gap = ButtonGroupDefaults.ConnectedSpaceBetween
val segment = (maxWidth - gap * (options.size - 1)) / options.size
Row(
Modifier.fillMaxWidth().selectableGroup(),
horizontalArrangement = Arrangement.spacedBy(gap),
) {
options.forEachIndexed { index, (value, label) ->
val checked = value == selected
val text = with(density) { measurer.measure(label, style, maxLines = 1).size.width.toDp() }
val check = checked && mark && text + CHECK + PADDING * 2 <= segment
ToggleButton(
checked = checked,
onCheckedChange = {
if (!checked) haptic.performHapticFeedback(HapticFeedbackType.SegmentTick)
onSelect(value)
},
modifier = Modifier.weight(1f).semantics { role = Role.RadioButton },
// Unselected segments need their own tonal fill to read as one group.
colors = ToggleButtonDefaults.colors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
checkedContainerColor = MaterialTheme.colorScheme.primary,
checkedContentColor = MaterialTheme.colorScheme.onPrimary,
),
shapes = when (index) {
0 -> ButtonGroupDefaults.connectedLeadingButtonShapes()
options.lastIndex -> ButtonGroupDefaults.connectedTrailingButtonShapes()
else -> ButtonGroupDefaults.connectedMiddleButtonShapes()
},
contentPadding = PaddingValues(horizontal = PADDING),
) {
if (check) {
Icon(Icons.Rounded.Check, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(6.dp))
}
Text(label, style = style, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
Text(label, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
}
/** A connected button's side padding, and its check with the gap after it. */
private val PADDING = 12.dp
private val CHECK = 24.dp
/** M3 search input field on a tonal container; reports every edit through [onQueryChange]. */
@Composable
fun SearchField(query: String, onQueryChange: (String) -> Unit, placeholder: String, modifier: Modifier = Modifier) {
@@ -355,6 +408,10 @@ fun weekTintOf(week: Week?): Color {
* 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
* 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
fun DayStrip(
@@ -364,8 +421,38 @@ fun DayStrip(
enabled: (LocalDate) -> Boolean = { true },
onSelect: (LocalDate) -> Unit,
onWeek: ((Int) -> Unit)? = null,
marks: Boolean = false,
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(
modifier
.fillMaxWidth()
@@ -378,14 +465,48 @@ fun DayStrip(
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 ->
val date = monday.plusDays(i.toLong())
val isSelected = date == selected
val isToday = date == today()
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(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
@@ -397,7 +518,10 @@ fun DayStrip(
role = Role.Tab,
interactionSource = null,
indication = null,
onClick = { onSelect(date) },
onClick = {
if (!isSelected) haptic.performHapticFeedback(HapticFeedbackType.SegmentTick)
onSelect(date)
},
)
.padding(vertical = 6.dp)
.semantics(mergeDescendants = true) {
@@ -415,17 +539,16 @@ fun DayStrip(
modifier = Modifier
.padding(top = 4.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(
"${date.dayOfMonth}",
style = MaterialTheme.typography.titleMediumEmphasized,
color = when {
isSelected -> scheme.onPrimary
isToday -> scheme.primary
busy -> scheme.onSurface
else -> scheme.onSurfaceVariant
},
Text("${date.dayOfMonth}", style = MaterialTheme.typography.titleMediumEmphasized, color = ink)
if (marks) Box(
Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 4.dp)
.size(4.dp)
.graphicsLayer { scaleX = dot.value; scaleY = dot.value }
.background(ink, CircleShape),
)
}
}

View File

@@ -0,0 +1,121 @@
package org.xtimms.psu.schedule.ui.components
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
import org.xtimms.psu.schedule.ui.motion.EmphasizedDecelerate
import org.xtimms.psu.schedule.ui.motion.rememberReducedMotion
// Finishing a task: its text is struck through from left to right, line after line, and once the
// last open task of the plans is done a small check pops up by "Всё сделано".
/** 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

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

View File

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

View File

@@ -0,0 +1,196 @@
package org.xtimms.psu.schedule.ui.components
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
import org.xtimms.psu.schedule.ui.motion.rememberReducedMotion
// Placeholders in the shape of what is loading: the first timetable of a group, a teacher's day,
// the catalog. The screen keeps its layout instead of a lone spinner in the middle.
/**
* 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

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

View File

@@ -0,0 +1,75 @@
package org.xtimms.psu.schedule.ui.components
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()
}
}
}
/** A message with nothing to take back: "У предмета больше нет пар в расписании". */
fun say(message: String) {
scope.launch {
state.currentSnackbarData?.dismiss()
state.showSnackbar(message, duration = SnackbarDuration.Short)
}
}
}
val LocalUndo = staticCompositionLocalOf<UndoHost?> { null }
/** 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

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

View File

@@ -1,6 +1,6 @@
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
package org.xtimms.psu.schedule.ui
package org.xtimms.psu.schedule.ui.groups
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.foundation.layout.height
@@ -56,6 +56,23 @@ import org.xtimms.psu.schedule.data.GroupResult
import org.xtimms.psu.schedule.data.attempt
import org.xtimms.psu.schedule.data.GroupsResponse
import org.xtimms.psu.schedule.data.SavedGroup
import org.xtimms.psu.schedule.data.choiceKey
import org.xtimms.psu.schedule.ui.MainViewModel
import org.xtimms.psu.schedule.ui.components.Banner
import org.xtimms.psu.schedule.ui.components.CardRow
import org.xtimms.psu.schedule.ui.components.ConnectedChoice
import org.xtimms.psu.schedule.ui.components.ContentColumn
import org.xtimms.psu.schedule.ui.components.Empty
import org.xtimms.psu.schedule.ui.components.Heading
import org.xtimms.psu.schedule.ui.components.ListCard
import org.xtimms.psu.schedule.ui.components.RowDivider
import org.xtimms.psu.schedule.ui.components.RowsSkeleton
import org.xtimms.psu.schedule.ui.components.SearchField
import org.xtimms.psu.schedule.ui.components.appBarColors
import org.xtimms.psu.schedule.ui.components.avatarLabel
import org.xtimms.psu.schedule.ui.components.navBarPadding
import org.xtimms.psu.schedule.ui.lesson.subjectColor
import org.xtimms.psu.schedule.ui.notes.open
private val PLAIN_PERIOD = Regex("^(осенний|весенний) семестр$|^расписание из word$", RegexOption.IGNORE_CASE)
@@ -97,15 +114,25 @@ fun GroupsScreen(
}
val searching = query.trim().length >= 2
// Before going back to the overview, a group with subgroups asks which one is yours,
// unless you already chose it for this group.
// Before going back to the overview, a group with subgroups asks which one is yours, and a
// group whose parts study German or French which you study, unless you chose it already.
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() {
val t = vm.timetable.value
val subgroups = t?.subgroups.orEmpty()
val s = vm.settings.value
val chosen = s?.current?.group == t?.group && s?.subgroup != null
if (subgroups.isNotEmpty() && !chosen && !onboarding) askSubgroup = subgroups else onOpened()
val same = s?.current?.group == t?.group
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) {
@@ -169,6 +196,12 @@ fun GroupsScreen(
subgroups = timetable?.subgroups.orEmpty(),
subgroup = settings?.subgroup,
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 }
@@ -233,9 +266,7 @@ fun GroupsScreen(
}
}
}
if (data == null && error == null) item {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { LoadingIndicator() }
}
if (data == null && error == null) item { RowsSkeleton() }
}
}
}
@@ -244,7 +275,32 @@ fun GroupsScreen(
SubgroupDialog(
group = vm.timetable.value?.group.orEmpty(),
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 +326,18 @@ private fun GroupRows(results: List<GroupResult>, opening: String?, onOpen: (Gro
}
@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, "Сейчас открыта") {
CardRow(
avatar = avatarLabel(current.group),
@@ -291,9 +358,113 @@ private fun CurrentGroup(current: SavedGroup, subgroups: List<String>, subgroup:
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. */
@Composable
private fun SubgroupDialog(group: String, subgroups: List<String>, onPick: (String?) -> Unit) {

View File

@@ -1,4 +1,4 @@
package org.xtimms.psu.schedule.ui
package org.xtimms.psu.schedule.ui.home
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -36,10 +36,12 @@ import org.xtimms.psu.schedule.data.longLabel
import org.xtimms.psu.schedule.data.parseDate
import org.xtimms.psu.schedule.data.plural
import org.xtimms.psu.schedule.data.shortWeekday
import org.xtimms.psu.schedule.data.withPlace
import org.xtimms.psu.schedule.ui.theme.card
import java.time.Instant
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import org.xtimms.psu.schedule.ui.components.Heading
/** "3 изменения". */
fun changesLabel(n: Int) = "$n ${plural(n, "изменение", "изменения", "изменений")}"
@@ -106,7 +108,7 @@ fun ChangesSheet(record: ScheduleChanges) {
if (record.added.isNotEmpty()) {
Heading("Добавлено · ${record.added.size}", modifier = Modifier.padding(top = 8.dp))
record.added.forEach { row ->
ChangeItem(row, details = listOf(row.lesson.type, row.lesson.teachers.joinToString(", "), row.lesson.rooms.joinToString(", ") { "ауд. $it" }, row.lesson.condition))
ChangeItem(row, details = listOf(row.lesson.type, row.lesson.teachers.joinToString(", "), withPlace(row.lesson.rooms.joinToString(", ") { "ауд. $it" }, row.lesson.place).orEmpty(), row.lesson.condition))
}
}
if (record.removed.isNotEmpty()) {

View File

@@ -1,12 +1,11 @@
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
package org.xtimms.psu.schedule.ui
package org.xtimms.psu.schedule.ui.home
import androidx.compose.animation.AnimatedContent
import androidx.compose.material3.IconButton
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowLeft
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.ui.draw.clip
import androidx.compose.ui.semantics.clearAndSetSemantics
@@ -62,7 +61,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface
@@ -71,7 +69,6 @@ import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -85,50 +82,71 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.delay
import org.xtimms.psu.schedule.data.DayPlace
import org.xtimms.psu.schedule.data.Lesson
import org.xtimms.psu.schedule.data.Timetable
import org.xtimms.psu.schedule.data.longLabel
import org.xtimms.psu.schedule.data.minutesLabel
import org.xtimms.psu.schedule.data.monday
import org.xtimms.psu.schedule.data.nowTime
import org.xtimms.psu.schedule.data.parseTime
import org.xtimms.psu.schedule.data.plural
import org.xtimms.psu.schedule.data.today
import org.xtimms.psu.schedule.data.weekSpan
import org.xtimms.psu.schedule.data.weekdayName
import org.xtimms.psu.schedule.data.withPlace
import java.time.Duration
import java.time.LocalDate
import java.time.LocalTime
/** One time slot of a day: parallel lessons of subgroups share it. */
data class Slot(val date: LocalDate, val start: LocalTime, val end: LocalTime, val lessons: List<Lesson>)
fun MainViewModel.slotsOn(t: Timetable, date: LocalDate, subgroup: String?): List<Slot> =
repository.lessonsOn(t, date, subgroup, hidden)
.groupBy { it.start }
.map { (start, lessons) -> Slot(date, parseTime(start), lessonEnd(lessons.first(), t), lessons) }
.sortedBy { it.start }
/** The current time, refreshed every half minute while the screen is shown. */
@Composable
fun rememberClock(): LocalTime {
var clock by remember { mutableStateOf(nowTime()) }
LaunchedEffect(Unit) {
while (true) {
delay(30_000)
clock = nowTime()
}
}
return clock
}
import org.xtimms.psu.schedule.ui.AppNav
import org.xtimms.psu.schedule.ui.MainViewModel
import org.xtimms.psu.schedule.ui.MenuButton
import org.xtimms.psu.schedule.ui.components.Banner
import org.xtimms.psu.schedule.ui.components.CenteredEmpty
import org.xtimms.psu.schedule.ui.components.ContentColumn
import org.xtimms.psu.schedule.ui.components.DetailPane
import org.xtimms.psu.schedule.ui.components.Empty
import org.xtimms.psu.schedule.ui.components.Heading
import org.xtimms.psu.schedule.ui.components.ListCard
import org.xtimms.psu.schedule.ui.components.LocalUndo
import org.xtimms.psu.schedule.ui.components.OverviewSkeleton
import org.xtimms.psu.schedule.ui.components.PaneRow
import org.xtimms.psu.schedule.ui.components.TimeText
import org.xtimms.psu.schedule.ui.components.WeekMark
import org.xtimms.psu.schedule.ui.components.appBarColors
import org.xtimms.psu.schedule.ui.components.navBarPadding
import org.xtimms.psu.schedule.ui.components.twoPane
import org.xtimms.psu.schedule.ui.lesson.AttendanceCard
import org.xtimms.psu.schedule.ui.lesson.DayPlaces
import org.xtimms.psu.schedule.ui.lesson.LocalDisplay
import org.xtimms.psu.schedule.ui.lesson.SessionBadge
import org.xtimms.psu.schedule.ui.lesson.SessionDot
import org.xtimms.psu.schedule.ui.lesson.Slot
import org.xtimms.psu.schedule.ui.lesson.SubjectDot
import org.xtimms.psu.schedule.ui.lesson.placesOn
import org.xtimms.psu.schedule.ui.lesson.rememberClock
import org.xtimms.psu.schedule.ui.lesson.rememberLessonOpener
import org.xtimms.psu.schedule.ui.lesson.session
import org.xtimms.psu.schedule.ui.lesson.sessionColors
import org.xtimms.psu.schedule.ui.lesson.slotsOn
import org.xtimms.psu.schedule.ui.motion.AnimatedItem
import org.xtimms.psu.schedule.ui.motion.Entrance
import org.xtimms.psu.schedule.ui.motion.ExpressiveRefresh
import org.xtimms.psu.schedule.ui.motion.RollingText
import org.xtimms.psu.schedule.ui.motion.materialSharedAxisX
import org.xtimms.psu.schedule.ui.motion.morphOrigin
import org.xtimms.psu.schedule.ui.motion.rememberSlideDistance
import org.xtimms.psu.schedule.ui.notes.LocalNotes
import org.xtimms.psu.schedule.ui.notes.NoteEditor
import org.xtimms.psu.schedule.ui.notes.NoteRow
import org.xtimms.psu.schedule.ui.schedule.WeekGrid
import org.xtimms.psu.schedule.ui.lesson.lessonPress
@Composable
fun HomeScreen(vm: MainViewModel, nav: AppNav) {
val undo = LocalUndo.current
val timetable by vm.timetable.collectAsStateWithLifecycle()
val settings by vm.settings.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 session by remember { mutableStateOf(false) }
val changes by vm.changes.collectAsStateWithLifecycle()
@@ -175,17 +193,17 @@ 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) }
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
when {
t == null && status.loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { LoadingIndicator() }
t == null && status.loading -> OverviewSkeleton()
t == null -> CenteredEmpty {
Empty(
"Расписание не загружено",
"Выберите группу — обзор дня и недели появится здесь.",
icon = Icons.Rounded.Groups,
// A chosen group that failed to load gets another try; otherwise pick one.
action = if (settings?.current != null) "Повторить" to { vm.refresh(); Unit } else "Выбрать группу" to nav.openGroups,
action = if (settings?.current != null && !status.needsGroup) "Повторить" to { vm.refresh(); Unit } else "Выбрать группу" to nav.openGroups,
)
}
t.semester == null -> Column(Modifier.padding(top = 8.dp)) {
@@ -194,8 +212,10 @@ fun HomeScreen(vm: MainViewModel, nav: AppNav) {
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 intro = remember { vm.takeIntro() }
val overview: @Composable (Modifier) -> Unit = { listModifier ->
val todaySlots = vm.slotsOn(t, date, subgroup)
val next = (1..7).asSequence()
@@ -223,10 +243,10 @@ fun HomeScreen(vm: MainViewModel, nav: AppNav) {
}
}
}
item(key = "week") { AnimatedItem { WeekReport(vm, t, subgroup, date, clock) } }
item(key = "session") { AnimatedItem { SessionCard(vm, t, onOpen = { session = true }) } }
item(key = "today-title") { AnimatedItem { DayTitle("Сегодня", date) } }
item(key = "today") { AnimatedItem { TodayCard(todaySlots, clock, onOpen = { details = it to date }, onDay = { nav.openDay(date) }) } }
item(key = "week") { AnimatedItem { Entrance(0, intro) { WeekReport(vm, t, subgroup, date, clock) } } }
item(key = "session") { AnimatedItem { Entrance(1, intro) { SessionCard(vm, t, onOpen = { session = true }) } } }
item(key = "today-title") { AnimatedItem { Entrance(2, intro) { DayTitle("Сегодня", 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 = "tasks") { AnimatedItem { TasksCard(vm, onEdit = { editing = it }, onAll = nav.openPlans) } }
if (next != null) {
@@ -234,7 +254,8 @@ fun HomeScreen(vm: MainViewModel, nav: AppNav) {
item(key = "next-title") { AnimatedItem { DayTitle(if (day == date.plusDays(1)) "Завтра" else day.weekdayName(), day) } }
item(key = "next") {
SectionCard(Icons.Rounded.EventAvailable, plural(slots.size, "пара", "пары", "пар").let { "${slots.size} $it" }) {
slots.forEach { slot -> slot.lessons.forEach { CompactLesson(it, slot, onClick = { details = it to day }) } }
DayPlaces(vm.placesOn(t, day, subgroup))
slots.forEach { slot -> slot.lessons.forEach { CompactLesson(it, slot, onClick = { openLesson(it, day) }) } }
DayButton { nav.openDay(day) }
}
}
@@ -245,10 +266,10 @@ fun HomeScreen(vm: MainViewModel, nav: AppNav) {
// 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) }) {
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()) }
}
} }
}
}
if (changesOpen) changes?.let { record ->
@@ -264,13 +285,10 @@ fun HomeScreen(vm: MainViewModel, nav: AppNav) {
editing = note,
groupKey = note.group,
onSave = { vm.saveNote(it); editing = null },
onDelete = { vm.deleteNote(it.id); editing = null },
onDelete = { vm.deleteNote(it.id, undo); 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. */
@@ -329,7 +347,7 @@ private fun TasksCard(vm: MainViewModel, onEdit: (Note) -> Unit, onAll: () -> Un
},
) {
(if (expanded) pending else pending.take(4)).forEach { note ->
NoteRow(note, onToggle = { vm.toggleNote(note.id) }, onClick = { onEdit(note) }, showSubject = true)
NoteRow(note, onToggle = { vm.toggleNote(note.id) }, onClick = { onEdit(note) }, showSubject = true, onPostpone = { vm.postponeNote(note, it) })
}
Row(Modifier.padding(start = 12.dp)) {
if (pending.size > 4) TextButton(onClick = { expanded = !expanded }) {
@@ -444,7 +462,7 @@ private fun DayTitle(label: String, date: LocalDate) {
}
@Composable
private fun TodayCard(slots: List<Slot>, clock: LocalTime, onOpen: (Lesson) -> Unit, onDay: () -> Unit) {
private fun TodayCard(slots: List<Slot>, places: List<DayPlace>, clock: LocalTime, onOpen: (Lesson) -> Unit, onDay: () -> Unit) {
val left = slots.filter { it.end.isAfter(clock) }
SectionCard(Icons.Rounded.Schedule, "Следующие занятия") {
when {
@@ -454,6 +472,7 @@ private fun TodayCard(slots: List<Slot>, clock: LocalTime, onOpen: (Lesson) -> U
"Сегодня было ${slots.size} ${plural(slots.size, "пара", "пары", "пар")}.",
)
else -> {
DayPlaces(places)
val first = left.first()
first.lessons.forEach { FeaturedLesson(it, first, clock, onClick = { onOpen(it) }) }
if (left.size > 1) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
@@ -502,11 +521,6 @@ private fun DayButton(onClick: () -> Unit) {
}
}
@Composable
fun SubjectDot(subject: String, size: Dp = 12.dp, modifier: Modifier = Modifier) {
Box(modifier.size(size).background(subjectColor(subject).container, CircleShape))
}
private fun Slot.timeLabel() = "$start → $end"
/** The next lesson in full: subject, time with a countdown, room and teacher. */
@@ -516,8 +530,9 @@ private fun FeaturedLesson(lesson: Lesson, slot: Slot, clock: LocalTime, onClick
Row(
Modifier
.fillMaxWidth()
.morphOrigin("featured|${slot.date}|${lesson.key}|${lesson.start}", MaterialTheme.colorScheme.card, 12.dp)
.clip(MaterialTheme.shapes.medium)
.clickable(onClickLabel = "Подробнее", role = Role.Button, onClick = onClick),
.lessonPress(lesson, slot.date, onClick),
) {
SubjectDot(lesson.subject, 14.dp, Modifier.padding(top = 8.dp))
Spacer(Modifier.width(16.dp))
@@ -533,7 +548,7 @@ private fun FeaturedLesson(lesson: Lesson, slot: Slot, clock: LocalTime, onClick
color = if (running) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.secondaryContainer,
contentColor = if (running) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSecondaryContainer,
) {
Text(
RollingText(
if (running) "Идёт · ещё ${minutesLabel(minutes)}" else "Через ${minutesLabel(minutes)}",
style = MaterialTheme.typography.labelLarge,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
@@ -541,7 +556,7 @@ private fun FeaturedLesson(lesson: Lesson, slot: Slot, clock: LocalTime, onClick
}
}
val show = LocalDisplay.current
val room = if (lesson.distance) "Дистанционно" else lesson.rooms.joinToString(", ").takeIf { show.rooms && it.isNotEmpty() }?.let { "Ауд. $it" }
val room = if (lesson.distance) "Дистанционно" else withPlace(lesson.rooms.joinToString(", ").takeIf { show.rooms && it.isNotEmpty() }?.let { "Ауд. $it" }, lesson.place)
room?.let { HomeMeta(Icons.Rounded.LocationOn, it) }
if (show.teachers && lesson.teachers.isNotEmpty()) HomeMeta(Icons.Rounded.Person, lesson.teachers.joinToString(", "))
}
@@ -556,7 +571,10 @@ fun CompactLesson(lesson: Lesson, slot: Slot, onClick: (() -> Unit)?) {
Modifier
.fillMaxWidth()
.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)
.lessonPress(lesson, slot.date, onClick)
else Modifier,
),
verticalAlignment = Alignment.CenterVertically,
@@ -570,7 +588,7 @@ fun CompactLesson(lesson: Lesson, slot: Slot, onClick: (() -> Unit)?) {
listOfNotNull(
session?.label,
slot.timeLabel(),
if (lesson.distance) "дистанционно" else lesson.rooms.joinToString(", ").takeIf { show.rooms && it.isNotEmpty() }?.let { "ауд. $it" },
if (lesson.distance) "дистанционно" else withPlace(lesson.rooms.joinToString(", ").takeIf { show.rooms && it.isNotEmpty() }?.let { "ауд. $it" }, lesson.place),
lesson.type.takeIf { show.tags && it.isNotEmpty() && session == null }?.lowercase(),
).joinToString(" · "),
style = MaterialTheme.typography.bodyMedium,

View File

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

View File

@@ -1,7 +1,9 @@
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
package org.xtimms.psu.schedule.ui
package org.xtimms.psu.schedule.ui.lesson
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -32,6 +34,12 @@ import org.xtimms.psu.schedule.data.Mark
import org.xtimms.psu.schedule.data.nowTime
import org.xtimms.psu.schedule.data.today
import java.time.LocalDate
import org.xtimms.psu.schedule.ui.MainViewModel
import org.xtimms.psu.schedule.ui.components.ConnectedChoice
import org.xtimms.psu.schedule.ui.components.Heading
import org.xtimms.psu.schedule.ui.components.ListCard
import org.xtimms.psu.schedule.ui.components.RowDivider
import org.xtimms.psu.schedule.ui.components.TimeText
/** Attendance marks of the open group, for cards deep in the tree. */
val LocalMarks = staticCompositionLocalOf<List<Mark>> { emptyList() }
@@ -83,6 +91,7 @@ fun LessonAttendance(vm: MainViewModel, lesson: Lesson, date: LocalDate) {
@Composable
fun AttendanceCard(vm: MainViewModel, slots: List<Slot>) {
val marks by vm.marks.collectAsStateWithLifecycle()
val haptic = LocalHapticFeedback.current
if (vm.currentGroupKey() == null) return
val now = nowTime()
val date = today()
@@ -103,11 +112,17 @@ fun AttendanceCard(vm: MainViewModel, slots: List<Slot>) {
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)
}
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}»")
}
FilledTonalIconButton(
onClick = { vm.mark(lesson, date, false) },
onClick = {
haptic.performHapticFeedback(HapticFeedbackType.Confirm)
vm.mark(lesson, date, false)
},
colors = IconButtonDefaults.filledTonalIconButtonColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,81 @@
package org.xtimms.psu.schedule.ui.lesson
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.LocationCity
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import org.xtimms.psu.schedule.data.DayPlace
import org.xtimms.psu.schedule.data.Timetable
import java.time.LocalDate
import org.xtimms.psu.schedule.ui.MainViewModel
// "Занятия в Полоцке": Word marks the days a group spends in another town over the day's first
// row. The server reads it as a note of the day, not a lesson, and gives each lesson its town.
fun MainViewModel.placesOn(t: Timetable, date: LocalDate, subgroup: String?): List<DayPlace> =
repository.placesOn(t, date, subgroup, hidden)
/** The day's note above its lessons: the town in a tertiary card, with the subgroup it is for. */
@Composable
fun DayPlaces(places: List<DayPlace>, modifier: Modifier = Modifier) {
if (places.isEmpty()) return
Surface(
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.tertiaryContainer,
contentColor = MaterialTheme.colorScheme.onTertiaryContainer,
modifier = modifier.fillMaxWidth(),
) {
Column(Modifier.padding(horizontal = 16.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
places.forEach { p ->
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Rounded.LocationCity, contentDescription = null, modifier = Modifier.size(24.dp))
Spacer(Modifier.width(14.dp))
Column {
Text(p.label, style = MaterialTheme.typography.titleMediumEmphasized)
Text(
listOfNotNull(p.subgroup, "пометка в Word на этот день").joinToString(" · ").replaceFirstChar { it.titlecase() },
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
}
}
}
/** The town under a day's date in the week grid: short, in the note's colour. */
@Composable
fun DayPlaceMark(places: List<DayPlace>, modifier: Modifier = Modifier) {
val first = places.firstOrNull() ?: return
Surface(
shape = MaterialTheme.shapes.extraSmall,
color = MaterialTheme.colorScheme.tertiaryContainer,
contentColor = MaterialTheme.colorScheme.onTertiaryContainer,
modifier = modifier.semantics { contentDescription = places.joinToString(", ") { listOfNotNull(it.label, it.subgroup).joinToString(", ") } },
) {
Text(
first.place,
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp),
)
}
}

View File

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

View File

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

View File

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

View File

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

@@ -0,0 +1,234 @@
package org.xtimms.psu.schedule.ui.motion
import androidx.compose.ui.Modifier
import androidx.compose.material3.MaterialTheme
import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridItemScope
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.layout.Box
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.ContentTransform
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import android.graphics.Path
import android.view.animation.PathInterpolator
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.Easing
import androidx.compose.animation.core.FastOutLinearInEasing
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalDensity
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
// opens, fade through between top-level destinations.
/** How far a shared axis transition slides: 30dp in pixels. */
@Composable
fun rememberSlideDistance(): Int {
val density = LocalDensity.current
return remember(density) { with(density) { 30.dp.roundToPx() } }
}
// A predictive back gesture can pause anywhere, so its fades overlap over the whole
// transition instead of handing over in the middle, as RideBus does.
private fun sharedAxisXIn(forward: Boolean, distance: Int, predictive: Boolean): EnterTransition =
slideInHorizontally(tween(300, easing = FastOutSlowInEasing)) { if (forward) distance else -distance } +
fadeIn(
if (predictive) tween(300, easing = LinearEasing)
else tween(durationMillis = 195, delayMillis = 105, easing = LinearOutSlowInEasing),
)
private fun sharedAxisXOut(forward: Boolean, distance: Int, predictive: Boolean): ExitTransition =
slideOutHorizontally(tween(300, easing = FastOutSlowInEasing)) { if (forward) -distance else distance } +
fadeOut(if (predictive) tween(300, easing = LinearEasing) else tween(durationMillis = 105, easing = FastOutLinearInEasing))
/** Forward into a page slides in from the end; back slides the other way. */
fun materialSharedAxisX(forward: Boolean, distance: Int, predictive: Boolean = false): ContentTransform =
sharedAxisXIn(forward, distance, predictive) togetherWith sharedAxisXOut(forward, distance, predictive)
/**
* md.sys.motion.easing.emphasized: the two-segment path M3 uses for large transitions that
* begin and end on screen. A single cubic only approximates it, so this follows the path itself.
*/
val Emphasized: Easing = run {
val path = Path().apply {
moveTo(0f, 0f)
cubicTo(0.05f, 0f, 0.133333f, 0.06f, 0.166666f, 0.4f)
cubicTo(0.208333f, 0.82f, 0.25f, 1f, 1f, 1f)
}
val interpolator = PathInterpolator(path)
Easing { interpolator.getInterpolation(it) }
}
/** md.sys.motion.duration tokens used here, in milliseconds. */
object MotionDuration {
const val Short4 = 200
const val Medium4 = 400
const val Long2 = 500
}
/** M3 emphasized easing for components entering the screen: fast start, long gentle stop. */
val EmphasizedDecelerate = CubicBezierEasing(0.05f, 0.7f, 0.1f, 1f)
/** M3 emphasized easing for components leaving: they speed up and come to rest just off screen. */
val EmphasizedAccelerate = CubicBezierEasing(0.3f, 0f, 0.8f, 0.15f)
/**
* A list item that eases into place: it fades in and out as it appears or leaves, slides
* when items around it change, and grows or shrinks with its content (a card that fills
* up or empties) instead of jumping.
*/
@Composable
fun LazyItemScope.AnimatedItem(content: @Composable () -> Unit) {
Box(Modifier.animateItem().animateContentSize(MaterialTheme.motionScheme.defaultSpatialSpec())) { content() }
}
/** The same for a grid item. */
@Composable
fun LazyStaggeredGridItemScope.AnimatedItem(content: @Composable () -> Unit) {
Box(Modifier.animateItem().animateContentSize(MaterialTheme.motionScheme.defaultSpatialSpec())) { content() }
}
/** Between sibling destinations: the old one fades out, the new one fades and grows in. */
fun materialFadeThrough(): ContentTransform =
(fadeIn(tween(400, easing = EmphasizedDecelerate)) + scaleIn(tween(400, easing = EmphasizedDecelerate), initialScale = 0.92f)) togetherWith
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

@@ -1,7 +1,12 @@
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
package org.xtimms.psu.schedule.ui
package org.xtimms.psu.schedule.ui.notes
import kotlinx.coroutines.launch
import kotlinx.coroutines.delay
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.material3.ExperimentalMaterial3Api
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.DatePickerDialog
import androidx.compose.material3.DatePicker
import androidx.compose.foundation.background
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.Box
import androidx.compose.foundation.layout.Column
@@ -62,6 +73,20 @@ import org.xtimms.psu.schedule.data.shortWeekday
import org.xtimms.psu.schedule.data.today
import org.xtimms.psu.schedule.data.weekdayName
import java.time.LocalDate
import org.xtimms.psu.schedule.ui.MainViewModel
import org.xtimms.psu.schedule.ui.components.ConnectedChoice
import org.xtimms.psu.schedule.ui.components.DONE_PAUSE
import org.xtimms.psu.schedule.ui.components.Heading
import org.xtimms.psu.schedule.ui.components.LocalUndo
import org.xtimms.psu.schedule.ui.components.StrikeText
import org.xtimms.psu.schedule.ui.lesson.SubjectDot
import org.xtimms.psu.schedule.ui.lesson.subjectKey
import org.xtimms.psu.schedule.ui.components.SwipeAction
import org.xtimms.psu.schedule.ui.components.SwipeActions
import org.xtimms.psu.schedule.ui.components.UndoHost
import androidx.compose.material.icons.rounded.Check
import androidx.compose.material.icons.automirrored.rounded.Undo
import androidx.compose.material.icons.rounded.EventRepeat
/** Tasks and notes of the open group, for cards deep in the tree. */
val LocalNotes = staticCompositionLocalOf<List<Note>> { emptyList() }
@@ -107,10 +132,48 @@ fun MainViewModel.nextOccurrence(t: Timetable, subject: String, after: LocalDate
.firstOrNull()
}
/** A task with its checkbox or a note with its icon; tap opens the editor. */
/**
* A task with its checkbox or a note with its icon; tap opens the editor. Swiped to the right a
* task is done (or open again); swiped to the left it moves to the next lesson of its subject with
* [onPostpone]. [swipe] is off where a sideways swipe pages, as on the plans board.
*/
@Composable
fun NoteRow(note: Note, onToggle: () -> Unit, onClick: () -> Unit, showSubject: Boolean, showDue: Boolean = true) {
fun NoteRow(
note: Note,
onToggle: () -> Unit,
onClick: () -> Unit,
showSubject: Boolean,
showDue: Boolean = true,
onPostpone: ((UndoHost?) -> Unit)? = null,
swipe: Boolean = true,
) {
val overdue = note.overdue()
var viewing by remember { mutableStateOf(false) }
val haptic = LocalHapticFeedback.current
// A ticked task is struck through in place first, then moves down to the done ones.
var shownDone by remember(note.id, note.done) { mutableStateOf(note.done) }
val scope = rememberCoroutineScope()
val undo = LocalUndo.current
fun check(done: Boolean) {
haptic.performHapticFeedback(if (done) HapticFeedbackType.Confirm else HapticFeedbackType.ToggleOff)
shownDone = done
if (done) scope.launch {
delay(DONE_PAUSE)
onToggle()
} else onToggle()
}
val scheme = MaterialTheme.colorScheme
val doneAction = if (!swipe || !note.task) null
else if (!shownDone) SwipeAction(Icons.Rounded.Check, "Выполнено", scheme.primary, scheme.onPrimary) { check(true) }
else SwipeAction(Icons.AutoMirrored.Rounded.Undo, "Не выполнено", scheme.secondary, scheme.onSecondary) { check(false) }
val laterAction = if (!swipe || onPostpone == null || (note.task && shownDone)) null
else SwipeAction(
Icons.Rounded.EventRepeat,
if (note.general) "Перенести на завтра" else "Перенести к следующей паре",
scheme.tertiary,
scheme.onTertiary,
) { onPostpone(undo) }
SwipeActions(doneAction, laterAction) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
@@ -119,20 +182,13 @@ fun NoteRow(note: Note, onToggle: () -> Unit, onClick: () -> Unit, showSubject:
.clickable(onClickLabel = "Изменить", role = Role.Button, onClick = onClick)
.padding(start = 8.dp, end = 16.dp, top = 2.dp, bottom = 2.dp),
) {
if (note.task) Checkbox(checked = note.done, onCheckedChange = { onToggle() })
if (note.task) Checkbox(checked = shownDone, onCheckedChange = ::check)
else Box(Modifier.size(48.dp), contentAlignment = Alignment.Center) {
Icon(Icons.Rounded.StickyNote2, contentDescription = "Заметка", tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
Spacer(Modifier.width(8.dp))
Column(Modifier.weight(1f).padding(vertical = 8.dp)) {
Text(
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,
)
StrikeText(note.text, done = note.task && shownDone, style = MaterialTheme.typography.bodyLarge, maxLines = 3)
Row(verticalAlignment = Alignment.CenterVertically) {
if (showSubject && !note.general) {
SubjectDot(note.subject, 8.dp)
@@ -155,7 +211,13 @@ 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 }, more = note.photos.size - 1)
}
}
}
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. */
@@ -182,11 +244,19 @@ fun NoteEditor(
val general = editing?.general ?: (target?.subject?.isBlank() == true)
var day by rememberSaveable { mutableStateOf((editing?.date?.let(::parseDate) ?: target?.date ?: today()).toEpochDay()) }
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() }
LaunchedEffect(Unit) { focus.requestFocus() }
AlertDialog(
onDismissRequest = onDismiss,
onDismissRequest = cancel,
icon = { Icon(if (task) Icons.AutoMirrored.Rounded.Assignment else Icons.Rounded.StickyNote2, contentDescription = null) },
title = {
Text(
@@ -198,7 +268,7 @@ fun NoteEditor(
)
},
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.verticalScroll(rememberScrollState())) {
ConnectedChoice(
options = listOf(true to "Задание", false to "Заметка"),
selected = task,
@@ -212,6 +282,19 @@ fun NoteEditor(
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences),
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) {
Text("День", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
OutlinedButton(onClick = { picking = true }, modifier = Modifier.fillMaxWidth()) {
@@ -241,7 +324,8 @@ fun NoteEditor(
},
confirmButton = {
TextButton(
enabled = text.isNotBlank(),
// A photo alone is a note too: "задание на фото".
enabled = text.isNotBlank() || photos.isNotEmpty(),
onClick = {
val base = editing ?: run {
val t = target ?: return@TextButton
@@ -249,16 +333,16 @@ fun NoteEditor(
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
onSave(dated.copy(text = text.trim(), task = task))
onSave(dated.copy(text = text.trim().ifEmpty { "Фото" }, task = task, photos = photos))
},
) { Text("Сохранить") }
},
dismissButton = {
Row {
if (editing != null) TextButton(onClick = { onDelete(editing) }) {
if (editing != null) TextButton(onClick = { store.discard(added); onDelete(editing) }) {
Text("Удалить", color = MaterialTheme.colorScheme.error)
}
TextButton(onClick = onDismiss) { Text("Отмена") }
TextButton(onClick = cancel) { Text("Отмена") }
}
},
)
@@ -280,6 +364,7 @@ fun NoteEditor(
/** The lesson sheet's section: this lesson's tasks and notes and a button to add one. */
@Composable
fun LessonNotes(vm: MainViewModel, lesson: Lesson, date: LocalDate) {
val undo = LocalUndo.current
val all by vm.notes.collectAsStateWithLifecycle()
val timetable by vm.timetable.collectAsStateWithLifecycle()
val settings by vm.settings.collectAsStateWithLifecycle()
@@ -296,7 +381,7 @@ fun LessonNotes(vm: MainViewModel, lesson: Lesson, date: LocalDate) {
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
notes.forEach { note ->
NoteRow(note, onToggle = { vm.toggleNote(note.id) }, onClick = { editing = note }, showSubject = false)
NoteRow(note, onToggle = { vm.toggleNote(note.id) }, onClick = { editing = note }, showSubject = false, onPostpone = { vm.postponeNote(note, it) })
}
FilledTonalButton(onClick = { adding = true }, modifier = Modifier.padding(top = 8.dp)) {
Icon(Icons.Rounded.Add, contentDescription = null, modifier = Modifier.size(18.dp))
@@ -317,7 +402,7 @@ fun LessonNotes(vm: MainViewModel, lesson: Lesson, date: LocalDate) {
editing = editing,
groupKey = group,
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 },
)
}
@@ -342,6 +427,11 @@ fun NotesHint(notes: List<Note>, color: androidx.compose.ui.graphics.Color) {
color = color,
maxLines = 1,
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

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

View File

@@ -1,6 +1,6 @@
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
package org.xtimms.psu.schedule.ui
package org.xtimms.psu.schedule.ui.onboarding
import android.Manifest
import android.os.Build
@@ -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.Checklist
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.DownloadForOffline
import androidx.compose.material3.Button
@@ -91,16 +93,28 @@ import org.xtimms.psu.schedule.notify.Reminders
import org.xtimms.psu.schedule.ui.theme.WeekColors
import org.xtimms.psu.schedule.ui.theme.page
import kotlin.coroutines.cancellation.CancellationException
import org.xtimms.psu.schedule.ui.MainViewModel
import org.xtimms.psu.schedule.ui.components.ConnectedChoice
import org.xtimms.psu.schedule.ui.components.Heading
import org.xtimms.psu.schedule.ui.components.SegmentedItem
import org.xtimms.psu.schedule.ui.components.SegmentedList
import org.xtimms.psu.schedule.ui.components.navBarPadding
import org.xtimms.psu.schedule.ui.groups.GroupsScreen
import org.xtimms.psu.schedule.ui.groups.choiceText
import org.xtimms.psu.schedule.ui.groups.foreignText
import org.xtimms.psu.schedule.ui.motion.materialSharedAxisX
import org.xtimms.psu.schedule.ui.motion.rememberSlideDistance
/** The first run, in the order a student needs it: what this is, their group, their subgroup, reminders. */
private enum class Step { Welcome, Group, Subgroup, 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. */
private val PAGE_WIDTH = 560.dp
/**
* 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.
*/
@Composable
@@ -108,14 +122,18 @@ fun Onboarding(vm: MainViewModel, onDone: () -> Unit) {
val timetable by vm.timetable.collectAsStateWithLifecycle()
val settings by vm.settings.collectAsStateWithLifecycle()
val subgroups = timetable?.subgroups.orEmpty()
val choices = timetable?.choices.orEmpty()
var step by rememberSaveable { mutableStateOf(Step.Welcome) }
// 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)
fun previous(of: Step): Step = when (of) {
Step.Welcome, Step.Group -> Step.Welcome
Step.Subgroup -> Step.Group
Step.Reminders -> if (subgroups.isEmpty()) Step.Group else Step.Subgroup
}
val steps = listOfNotNull(
Step.Group,
Step.Subgroup.takeIf { subgroups.isNotEmpty() },
Step.Choice.takeIf { choices.isNotEmpty() },
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.
val state = remember { SeekableTransitionState(step) }
@@ -162,7 +180,16 @@ fun Onboarding(vm: MainViewModel, onDone: () -> Unit) {
Step.Welcome -> Welcome(onStart = { step = Step.Group })
Step.Group -> GroupsScreen(
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,
onboarding = true,
)
@@ -170,7 +197,18 @@ fun Onboarding(vm: MainViewModel, onDone: () -> Unit) {
group = timetable?.group.orEmpty(),
subgroups = subgroups,
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(
minutes = settings?.reminderMinutes ?: 10,
@@ -441,6 +479,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. */
@Composable
private fun RemindersStep(minutes: Int, onMinutes: (Int) -> Unit, onDone: (Boolean) -> Unit) {

View File

@@ -1,6 +1,6 @@
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
package org.xtimms.psu.schedule.ui
package org.xtimms.psu.schedule.ui.plans
import androidx.compose.animation.AnimatedContent
import androidx.compose.ui.unit.Dp
@@ -95,11 +95,50 @@ import org.xtimms.psu.schedule.data.longLabel
import org.xtimms.psu.schedule.data.monday
import org.xtimms.psu.schedule.data.today
import org.xtimms.psu.schedule.data.weekdayName
import org.xtimms.psu.schedule.data.withPlace
import org.xtimms.psu.schedule.ui.theme.page
import java.time.LocalDate
import java.time.YearMonth
import java.time.format.DateTimeFormatter
import java.util.Locale
import org.xtimms.psu.schedule.ui.AppNav
import org.xtimms.psu.schedule.ui.MainViewModel
import org.xtimms.psu.schedule.ui.MenuButton
import org.xtimms.psu.schedule.ui.components.CenteredEmpty
import org.xtimms.psu.schedule.ui.components.ContentColumn
import org.xtimms.psu.schedule.ui.components.DONE_PAUSE
import org.xtimms.psu.schedule.ui.components.DetailPane
import org.xtimms.psu.schedule.ui.components.Empty
import org.xtimms.psu.schedule.ui.components.Heading
import org.xtimms.psu.schedule.ui.components.LocalPaneColor
import org.xtimms.psu.schedule.ui.components.LocalUndo
import org.xtimms.psu.schedule.ui.components.MAX_CONTENT
import org.xtimms.psu.schedule.ui.components.PaneRow
import org.xtimms.psu.schedule.ui.components.StrikeText
import org.xtimms.psu.schedule.ui.components.TimeText
import org.xtimms.psu.schedule.ui.components.navBarPadding
import org.xtimms.psu.schedule.ui.components.twoPane
import org.xtimms.psu.schedule.ui.components.weekTintOf
import org.xtimms.psu.schedule.ui.lesson.DayPlaces
import org.xtimms.psu.schedule.ui.lesson.SessionDot
import org.xtimms.psu.schedule.ui.lesson.placesOn
import org.xtimms.psu.schedule.ui.lesson.rememberLessonOpener
import org.xtimms.psu.schedule.ui.lesson.session
import org.xtimms.psu.schedule.ui.lesson.sessionColors
import org.xtimms.psu.schedule.ui.lesson.slotsOn
import org.xtimms.psu.schedule.ui.lesson.subjectColor
import org.xtimms.psu.schedule.ui.motion.AnimatedItem
import org.xtimms.psu.schedule.ui.motion.Emphasized
import org.xtimms.psu.schedule.ui.motion.LocalMorph
import org.xtimms.psu.schedule.ui.motion.MorphKind
import org.xtimms.psu.schedule.ui.motion.MotionDuration
import org.xtimms.psu.schedule.ui.motion.materialSharedAxisX
import org.xtimms.psu.schedule.ui.motion.morphOrigin
import org.xtimms.psu.schedule.ui.motion.rememberSlideDistance
import org.xtimms.psu.schedule.ui.notes.NoteEditor
import org.xtimms.psu.schedule.ui.notes.NoteTarget
import org.xtimms.psu.schedule.ui.notes.general
import org.xtimms.psu.schedule.ui.motion.morphContent
private val MONTH = DateTimeFormatter.ofPattern("LLLL yyyy", Locale.forLanguageTag("ru"))
private const val MONTHS_BEFORE = 2
@@ -122,13 +161,14 @@ private fun weekRow(month: YearMonth, d: LocalDate) = mondays(month).indexOf(d.m
*/
@Composable
fun CalendarScreen(vm: MainViewModel, nav: AppNav) {
val undo = LocalUndo.current
val timetable by vm.timetable.collectAsStateWithLifecycle()
val settings by vm.settings.collectAsStateWithLifecycle()
val notes by vm.notes.collectAsStateWithLifecycle()
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 adding by remember { mutableStateOf(false) }
val morph = LocalMorph.current
var target by remember { mutableStateOf<NoteTarget?>(null) }
val base = remember { YearMonth.from(today()).minusMonths(MONTHS_BEFORE.toLong()) }
val pager = rememberPagerState(initialPage = MONTHS_BEFORE) { MONTHS }
@@ -253,7 +293,7 @@ fun CalendarScreen(vm: MainViewModel, nav: AppNav) {
val agenda: @Composable (Modifier) -> Unit = { modifier ->
DayAgenda(
vm, t, subgroup, selected, notes,
onLesson = { details = it to selected },
onLesson = { openLesson(it, selected) },
onNote = { editing = it },
modifier = modifier,
)
@@ -303,15 +343,23 @@ fun CalendarScreen(vm: MainViewModel, nav: AppNav) {
}
}
}
// "+" grows into the sheet of a new plan for the chosen day (Morph.kt).
FloatingActionButton(
onClick = { adding = true },
modifier = Modifier.align(Alignment.BottomEnd).padding(end = 16.dp, bottom = 16.dp + navBarPadding()),
) { Icon(Icons.Rounded.Add, contentDescription = "Новый план") }
onClick = {
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, elevation = 6.dp),
) { Icon(Icons.Rounded.Add, contentDescription = "Новый план", modifier = Modifier.morphContent("fab|calendar")) }
}
if (adding) ModalBottomSheet(onDismissRequest = { adding = false }) {
AddPlanSheet(vm, t, subgroup, day = selected) { adding = false; target = it }
}
target?.let { tg ->
vm.currentGroupKey()?.let { group ->
NoteEditor(
@@ -324,18 +372,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 ->
NoteEditor(
target = null,
editing = note,
groupKey = note.group,
onSave = { vm.saveNote(it); editing = null },
onDelete = { vm.deleteNote(it.id); editing = null },
onDelete = { vm.deleteNote(it.id, undo); editing = null },
onDismiss = { editing = null },
)
}
@@ -468,6 +511,7 @@ private fun DayAgendaContent(
val plans = notes.filter { it.date == date.toString() }.sortedWith(compareBy({ it.task && it.done }, { it.start }))
val known = t?.days?.any { it.date == date.toString() } == true
val slots = if (t != null && known) vm.slotsOn(t, date, subgroup) else emptyList()
val places = if (t != null && known) vm.placesOn(t, date, subgroup) else emptyList()
val lessons = slots.flatMap { slot -> slot.lessons.map { slot to it } }
if (plans.isEmpty() && lessons.isEmpty()) {
@@ -502,11 +546,22 @@ private fun DayAgendaContent(
item(key = "plans") { AnimatedItem { Section(number, "Ваши планы", trailing = date.format(DATE)) } }
plans.forEachIndexed { i, note ->
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(
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 },
title = note.text,
done = note.task && note.done,
done = note.task && shownDone,
last = i == plans.lastIndex,
onClick = { onNote(note) },
)
@@ -519,6 +574,7 @@ private fun DayAgendaContent(
item(key = "lessons") { AnimatedItem {
Section(number, "Ваши занятия", trailing = if (number == 1) date.format(DATE) else null, gap = number > 1)
} }
if (places.isNotEmpty()) item(key = "places") { AnimatedItem { DayPlaces(places, Modifier.padding(top = 8.dp, bottom = 12.dp)) } }
lessons.forEachIndexed { i, (slot, l) ->
item(key = "${slot.start}|${l.key}") {
val color = subjectColor(l.subject)
@@ -532,11 +588,12 @@ private fun DayAgendaContent(
overColor = if (session != null) sessionColors().accent else null,
title = l.subject,
under = listOfNotNull(
if (l.distance) "дистанционно" else l.rooms.joinToString(", ").ifEmpty { null }?.let { "ауд. $it" },
if (l.distance) "дистанционно" else withPlace(l.rooms.joinToString(", ").ifEmpty { null }?.let { "ауд. $it" }, l.place),
l.type.ifEmpty { null }?.takeIf { session == null }?.lowercase(),
).joinToString(" · ").ifEmpty { null },
last = i == lessons.lastIndex,
onClick = { onLesson(l) },
origin = Modifier.morphOrigin("cal|$date|${l.key}|${l.start}", MaterialTheme.colorScheme.surfaceContainerLow, 16.dp),
)
}
}
@@ -590,6 +647,8 @@ private fun TimelineItem(
overColor: Color? = null,
last: Boolean,
onClick: () -> Unit,
/** For a lesson: where its details page grows from. */
origin: Modifier = Modifier,
) {
val line = MaterialTheme.colorScheme.outlineVariant
// Level with the first line of the title: 14dp padding, a 20dp line above it, a 24dp title line.
@@ -598,6 +657,7 @@ private fun TimelineItem(
Modifier
.fillMaxWidth()
.height(IntrinsicSize.Min)
.then(origin)
.clip(MaterialTheme.shapes.large)
.clickable(onClickLabel = "Подробнее", role = Role.Button, onClick = onClick),
) {
@@ -623,14 +683,7 @@ private fun TimelineItem(
over?.let {
TimeText(it, style = MaterialTheme.typography.bodyMedium, color = overColor ?: MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
}
Text(
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,
)
StrikeText(title, done = done, style = MaterialTheme.typography.titleMedium, maxLines = 2)
under?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1) }
}
}

View File

@@ -1,6 +1,6 @@
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
package org.xtimms.psu.schedule.ui
package org.xtimms.psu.schedule.ui.plans
import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.fadeOut
@@ -28,6 +28,7 @@ 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.navigationBarsPadding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
@@ -95,6 +96,38 @@ import org.xtimms.psu.schedule.data.weekdayName
import org.xtimms.psu.schedule.ui.theme.card
import org.xtimms.psu.schedule.ui.theme.page
import java.time.LocalDate
import org.xtimms.psu.schedule.ui.AppNav
import org.xtimms.psu.schedule.ui.MainViewModel
import org.xtimms.psu.schedule.ui.MenuButton
import org.xtimms.psu.schedule.ui.components.CenteredEmpty
import org.xtimms.psu.schedule.ui.components.DetailPane
import org.xtimms.psu.schedule.ui.components.DoneMark
import org.xtimms.psu.schedule.ui.components.Empty
import org.xtimms.psu.schedule.ui.components.Heading
import org.xtimms.psu.schedule.ui.components.LocalUndo
import org.xtimms.psu.schedule.ui.components.PaneRow
import org.xtimms.psu.schedule.ui.components.navBarPadding
import org.xtimms.psu.schedule.ui.components.twoPane
import org.xtimms.psu.schedule.ui.components.weekTint
import org.xtimms.psu.schedule.ui.home.CompactLesson
import org.xtimms.psu.schedule.ui.lesson.SessionDot
import org.xtimms.psu.schedule.ui.lesson.Slot
import org.xtimms.psu.schedule.ui.lesson.rememberLessonOpener
import org.xtimms.psu.schedule.ui.lesson.session
import org.xtimms.psu.schedule.ui.lesson.sessionColors
import org.xtimms.psu.schedule.ui.lesson.slotsOn
import org.xtimms.psu.schedule.ui.lesson.subjectColor
import org.xtimms.psu.schedule.ui.motion.LocalMorph
import org.xtimms.psu.schedule.ui.motion.MorphKind
import org.xtimms.psu.schedule.ui.motion.materialFadeThrough
import org.xtimms.psu.schedule.ui.motion.morphOrigin
import org.xtimms.psu.schedule.ui.notes.NoteEditor
import org.xtimms.psu.schedule.ui.notes.NoteRow
import org.xtimms.psu.schedule.ui.notes.NoteTarget
import org.xtimms.psu.schedule.ui.search.SearchWeekStrip
import org.xtimms.psu.schedule.ui.notes.overdue
import org.xtimms.psu.schedule.ui.components.UndoHost
import org.xtimms.psu.schedule.ui.motion.morphContent
/** "Сегодня • Суббота • 17 октября", "Завтра • …" or "Понедельник • 19 октября". */
private fun dayTitle(d: LocalDate): String = when (d) {
@@ -119,15 +152,33 @@ private sealed interface Row {
*/
@Composable
fun PlansScreen(vm: MainViewModel, nav: AppNav) {
val undo = LocalUndo.current
val all by vm.notes.collectAsStateWithLifecycle()
val timetable by vm.timetable.collectAsStateWithLifecycle()
val settings by vm.settings.collectAsStateWithLifecycle()
val prefs = settings?.plans ?: PlansPrefs()
var editing by remember { mutableStateOf<Note?>(null) }
var adding by remember { mutableStateOf(false) }
var tuning by remember { mutableStateOf(false) }
var details by remember { mutableStateOf<Pair<Lesson, LocalDate>?>(null) }
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()
LaunchedEffect(wantsTask) {
if (wantsTask) {
addPlan()
vm.newTask.value = false
}
}
var tuning by remember { mutableStateOf(false) }
val openLesson = rememberLessonOpener(vm)
var date by remember { mutableStateOf(today()) }
// Each layout keeps its own list, so the outgoing and incoming layouts can be on screen
// together while one fades through to the other.
@@ -167,11 +218,15 @@ fun PlansScreen(vm: MainViewModel, nav: AppNav) {
title = {
Column {
Text("Планы", style = MaterialTheme.typography.titleLargeEmphasized)
Text(
if (open == 0) "Всё сделано" else "$open ${plural(open, "дело", "дела", "дел")} впереди",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
androidx.compose.foundation.layout.Row(verticalAlignment = Alignment.CenterVertically) {
// The last task done: a check pops up by "Всё сделано".
DoneMark(open == 0)
Text(
if (open == 0) "Всё сделано" else "$open ${plural(open, "дело", "дела", "дел")} впереди",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
},
actions = {
@@ -199,7 +254,7 @@ fun PlansScreen(vm: MainViewModel, nav: AppNav) {
rows.getOrNull(i)?.date?.let { if (it != date) date = it }
}
}
PlanList(list, rows, vm::toggleNote, onEdit = { editing = it }, color = color)
PlanList(list, rows, vm::toggleNote, onEdit = { editing = it }, onPostpone = vm::postponeNote, color = color)
}
BoxWithConstraints(Modifier.fillMaxSize()) {
@@ -247,7 +302,7 @@ fun PlansScreen(vm: MainViewModel, nav: AppNav) {
DetailPane(it) {
DayAgenda(
vm, timetable, settings?.subgroup, date, all,
onLesson = { details = it to date },
onLesson = { openLesson(it, date) },
onNote = { editing = it },
modifier = Modifier.fillMaxSize().padding(top = 12.dp),
)
@@ -258,30 +313,22 @@ fun PlansScreen(vm: MainViewModel, nav: AppNav) {
}
}
FloatingActionButton(
onClick = { adding = true },
modifier = Modifier.align(Alignment.BottomEnd).padding(end = 16.dp, bottom = 16.dp + navBarPadding()),
) { Icon(Icons.Rounded.Add, contentDescription = "Новое задание") }
onClick = addPlan,
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(end = 16.dp, bottom = 16.dp + navBarPadding())
.morphOrigin("fab|plans", MaterialTheme.colorScheme.primaryContainer, 16.dp, elevation = 6.dp),
) { Icon(Icons.Rounded.Add, contentDescription = "Новое задание", modifier = Modifier.morphContent("fab|plans")) }
}
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) }
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(
target = target,
editing = editing,
groupKey = editing?.group ?: group,
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 },
)
}
@@ -317,6 +364,7 @@ private fun PlanList(
rows: List<Row>,
onToggle: (String) -> Unit,
onEdit: (Note) -> Unit,
onPostpone: (Note, UndoHost?) -> Unit,
color: Color = MaterialTheme.colorScheme.surface,
) {
if (rows.isEmpty()) {
@@ -341,7 +389,14 @@ private fun PlanList(
}
is Row.Item -> item(key = "n${row.note.id}") {
Box(Modifier.animateItem()) {
NoteRow(row.note, onToggle = { onToggle(row.note.id) }, onClick = { onEdit(row.note) }, showSubject = true, showDue = row.date == null)
NoteRow(
row.note,
onToggle = { onToggle(row.note.id) },
onClick = { onEdit(row.note) },
showSubject = true,
showDue = row.date == null,
onPostpone = { onPostpone(row.note, it) },
)
}
}
is Row.Divider -> item(key = "d$day") {
@@ -456,7 +511,8 @@ private fun Board(
color = MaterialTheme.colorScheme.card,
modifier = Modifier.fillMaxWidth().border(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), MaterialTheme.shapes.large),
) {
NoteRow(n, onToggle = { onToggle(n.id) }, onClick = { onEdit(n) }, showSubject = true, showDue = d == null)
// The board's columns page sideways: a sideways swipe on a task would fight them.
NoteRow(n, onToggle = { onToggle(n.id) }, onClick = { onEdit(n) }, showSubject = true, showDue = d == null, swipe = false)
}
}
}
@@ -557,6 +613,7 @@ internal fun AddPlanSheet(vm: MainViewModel, t: Timetable?, subgroup: String?, d
Column(
Modifier
.verticalScroll(rememberScrollState())
.navigationBarsPadding()
.padding(start = 24.dp, end = 24.dp, bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {

View File

@@ -1,8 +1,10 @@
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
package org.xtimms.psu.schedule.ui
package org.xtimms.psu.schedule.ui.schedule
import androidx.compose.ui.unit.Dp
import kotlinx.coroutines.flow.drop
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.animation.togetherWith
import androidx.compose.animation.Crossfade
import androidx.compose.animation.shrinkVertically
@@ -12,23 +14,12 @@ import androidx.compose.animation.expandVertically
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.AnimatedContent
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.foundation.layout.heightIn
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.Check
import androidx.compose.foundation.interaction.collectIsDraggedAsState
import androidx.compose.material.icons.rounded.VisibilityOff
import androidx.compose.material3.OutlinedButton
import org.xtimms.psu.schedule.data.hideKey
import org.xtimms.psu.schedule.data.hiddenIn
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.graphics.Color
import kotlin.math.abs
import androidx.compose.material.icons.rounded.Weekend
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialShapes
import androidx.compose.material3.toShape
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.widthIn
import androidx.compose.ui.platform.LocalDensity
@@ -44,12 +35,8 @@ import org.xtimms.psu.schedule.ui.theme.page
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -65,31 +52,21 @@ import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.CloudOff
import androidx.compose.material.icons.rounded.ErrorOutline
import androidx.compose.material.icons.rounded.EventBusy
import androidx.compose.material.icons.rounded.EventRepeat
import androidx.compose.material.icons.rounded.Groups
import androidx.compose.material.icons.rounded.LocationOn
import androidx.compose.material.icons.rounded.Person
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material.icons.rounded.CalendarViewWeek
import androidx.compose.material.icons.rounded.ViewDay
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.material.icons.rounded.Schedule
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -100,16 +77,49 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.launch
import org.xtimms.psu.schedule.data.Bell
import org.xtimms.psu.schedule.data.DayPlace
import org.xtimms.psu.schedule.data.Lesson
import org.xtimms.psu.schedule.data.Timetable
import org.xtimms.psu.schedule.data.dayName
import org.xtimms.psu.schedule.data.forSubgroup
import org.xtimms.psu.schedule.data.minutesLabel
import org.xtimms.psu.schedule.data.monday
import org.xtimms.psu.schedule.data.parseDate
import org.xtimms.psu.schedule.data.today
import java.time.Duration
import java.time.LocalDate
import org.xtimms.psu.schedule.ui.AppNav
import org.xtimms.psu.schedule.ui.MainViewModel
import org.xtimms.psu.schedule.ui.MenuButton
import org.xtimms.psu.schedule.ui.components.Banner
import org.xtimms.psu.schedule.ui.components.CenteredEmpty
import org.xtimms.psu.schedule.ui.components.ContentColumn
import org.xtimms.psu.schedule.ui.components.DayStrip
import org.xtimms.psu.schedule.ui.components.Empty
import org.xtimms.psu.schedule.ui.components.FoldSplit
import org.xtimms.psu.schedule.ui.components.Heading
import org.xtimms.psu.schedule.ui.components.LocalFoldTablet
import org.xtimms.psu.schedule.ui.components.LocalHinge
import org.xtimms.psu.schedule.ui.components.MAX_CONTENT
import org.xtimms.psu.schedule.ui.components.ScheduleSkeleton
import org.xtimms.psu.schedule.ui.components.TWO_PANE
import org.xtimms.psu.schedule.ui.components.WeekMark
import org.xtimms.psu.schedule.ui.components.appBarColors
import org.xtimms.psu.schedule.ui.components.navBarPadding
import org.xtimms.psu.schedule.ui.components.trackFold
import org.xtimms.psu.schedule.ui.components.weekTint
import org.xtimms.psu.schedule.ui.components.weekTintOf
import org.xtimms.psu.schedule.ui.lesson.DayPlaces
import org.xtimms.psu.schedule.ui.lesson.LessonCard
import org.xtimms.psu.schedule.ui.lesson.BetweenRow
import org.xtimms.psu.schedule.ui.lesson.between
import org.xtimms.psu.schedule.ui.lesson.Slot
import org.xtimms.psu.schedule.ui.lesson.placesOn
import org.xtimms.psu.schedule.ui.lesson.rememberClock
import org.xtimms.psu.schedule.ui.lesson.rememberLessonOpener
import org.xtimms.psu.schedule.ui.lesson.slotsOn
import org.xtimms.psu.schedule.ui.motion.AnimatedItem
import org.xtimms.psu.schedule.ui.motion.ExpressiveRefresh
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
@@ -124,12 +134,12 @@ fun ScheduleScreen(
val timetable by vm.timetable.collectAsStateWithLifecycle()
val settings by vm.settings.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 t = timetable
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()) {
TopAppBar(
colors = appBarColors(),
@@ -137,9 +147,9 @@ fun ScheduleScreen(
title = { Text(t?.group ?: settings?.current?.group ?: "Расписание", style = MaterialTheme.typography.titleLargeEmphasized) },
actions = { ScheduleMenu(vm, nav) },
)
status.message?.let { Banner(it, offline = status.offline, onClose = vm::dismissMessage, onRetry = if (status.offline) vm::retry 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 {
t == null && status.loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { LoadingIndicator() }
t == null && status.loading -> ScheduleSkeleton()
t == null -> CenteredEmpty {
Empty(
"Расписание не загружено",
@@ -149,12 +159,7 @@ fun ScheduleScreen(
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 })
}
}
details?.let { (lesson, date) ->
ModalBottomSheet(onDismissRequest = { details = null }) {
LessonDetails(lesson, timetable?.bells.orEmpty(), date, vm, onClose = { details = null })
else -> WordRows(t, subgroup, settings?.hidden.orEmpty(), onOpen = { openLesson(it, null) })
}
}
}
@@ -229,6 +234,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.
val strip = rememberPagerState(initialPage = mondays.indexOf(date.monday()).coerceAtLeast(0)) { mondays.size }
LaunchedEffect(date.monday()) {
@@ -332,13 +348,14 @@ private fun Planner(
enabled = { d -> dates.contains(d) },
onSelect = ::go,
onWeek = ::week,
marks = true,
modifier = Modifier.widthIn(max = MAX_CONTENT).padding(horizontal = 8.dp),
)
}
} }
}
status.message?.let {
Box(Modifier.background(MaterialTheme.colorScheme.page)) { Banner(it, offline = status.offline, onClose = vm::dismissMessage, onRetry = if (status.offline) vm::retry else null) }
Box(Modifier.background(MaterialTheme.colorScheme.page)) { 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 timetable: @Composable (Modifier) -> Unit = {
@@ -378,13 +395,17 @@ private fun Planner(
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 ->
val pageDate = dates[page]
DayPage(
date = pageDate,
slots = vm.slotsOn(t, pageDate, subgroup),
places = vm.placesOn(t, pageDate, subgroup),
onOpen = onOpen,
fresh = fresh,
onFreshShown = vm::freshShown,
)
}
}
@@ -402,6 +423,7 @@ private fun Planner(
date = pageDate,
week = t.days.firstOrNull { it.date == pageDate.toString() }?.week,
slots = vm.slotsOn(t, pageDate, subgroup),
places = vm.placesOn(t, pageDate, subgroup),
chosen = stand?.takeIf { it.first == pageDate }?.second,
clock = clock,
onOpen = { onOpen(it, pageDate) },
@@ -431,12 +453,31 @@ private fun Planner(
}
@Composable
private fun DayPage(date: LocalDate, slots: List<Slot>, 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 isToday = date == today()
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 } +
(1..now).count { i -> slots.getOrNull(i - 1)?.let { between(it, slots[i]) } != null }
list.scrollToItem(before)
}
ContentColumn {
LazyColumn(
state = list,
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 24.dp + navBarPadding()),
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxSize(),
@@ -446,7 +487,13 @@ private fun DayPage(date: LocalDate, slots: List<Slot>, onOpen: (Lesson, LocalDa
Empty("Занятий нет", "По таблице недель в этот день свободно — можно отдохнуть.", icon = Icons.Rounded.Weekend)
}
}
slots.forEach { slot ->
// "Занятия в Полоцке": the day's town before its lessons.
if (places.isNotEmpty()) item(key = "places") { AnimatedItem { DayPlaces(places) } }
slots.forEachIndexed { index, slot ->
// A window, another building or another town since the lesson before.
slots.getOrNull(index - 1)?.let { prev -> between(prev, slot) }?.let { b ->
item(key = "between|${slot.start}") { AnimatedItem { BetweenRow(b) } }
}
val running = isToday && !clock.isBefore(slot.start) && clock.isBefore(slot.end)
val status = when {
running -> "Идёт · ещё ${minutesLabel(Duration.between(clock, slot.end).toMinutes())}"
@@ -464,7 +511,11 @@ private fun DayPage(date: LocalDate, slots: List<Slot>, onOpen: (Lesson, LocalDa
date = date,
status = status,
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) },
fresh = lesson.key in fresh,
onFreshShown = { onFreshShown(lesson.key) },
)
}
}
@@ -473,220 +524,6 @@ private fun DayPage(date: LocalDate, slots: List<Slot>, onOpen: (Lesson, LocalDa
}
}
/** A lesson as a soft card in its subject's colour, like School Planner's day list. */
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun LessonCard(
lesson: Lesson,
time: String?,
pair: Int? = null,
date: LocalDate? = null,
status: String? = null,
running: Boolean = false,
onClick: (() -> Unit)?,
) {
val color = subjectColor(lesson.subject)
val show = LocalDisplay.current
val session = lesson.session
val content: @Composable () -> Unit = {
Column(Modifier.padding(horizontal = 20.dp, vertical = 16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
// Exams, credits and consultations announce themselves before the subject.
session?.let { SessionBadge(it, Modifier.padding(bottom = 2.dp)) }
Row(verticalAlignment = Alignment.Top) {
Text(lesson.subject, style = MaterialTheme.typography.titleMediumEmphasized, modifier = Modifier.weight(1f))
status?.let {
Spacer(Modifier.width(8.dp))
Surface(
shape = MaterialTheme.shapes.small,
color = if (running) color.onSoft else color.onSoft.copy(alpha = 0.12f),
contentColor = if (running) color.soft else color.onSoft,
) {
Text(it, style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp))
}
}
}
time?.let { TimeText(it + (pair?.takeIf { show.pairs }?.let { p -> " · $p пара" } ?: ""), style = MaterialTheme.typography.bodyLarge) }
FlowRow(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
if (lesson.distance) Meta(Icons.Rounded.LocationOn, "Дистанционно")
else if (show.rooms && lesson.rooms.isNotEmpty()) Meta(
Icons.Rounded.LocationOn,
lesson.rooms.joinToString(", ") + if (lesson.roomMoved) " (перенос)" else "",
)
if (show.teachers && lesson.teachers.isNotEmpty()) Meta(Icons.Rounded.Person, lesson.teachers.joinToString(", "))
lesson.subgroup?.let { Meta(Icons.Rounded.Groups, it) }
}
val tags = if (!show.tags) emptyList() else listOfNotNull(
lesson.type.ifEmpty { null }?.takeIf { session == null },
if (lesson.variant != "all") (if (lesson.variant == "upper") "белые недели" else "зелёные недели") else null,
if (lesson.groups.size > 1) "общее занятие" else null,
)
val mark = date?.let { LocalMarks.current.forLesson(lesson.subject, it, lesson.start) }
if (tags.isNotEmpty() || lesson.uncertain || mark != null) FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.padding(top = 4.dp),
) {
mark?.let {
SoftTag(if (it.present) "был" else "пропуск", color, icon = if (it.present) Icons.Rounded.Check else Icons.Rounded.Close)
}
tags.forEach { SoftTag(it, color) }
if (lesson.uncertain) Tag("Сверьте недели", warning = true)
}
if (show.notes && lesson.condition.isNotEmpty()) Meta(Icons.Rounded.EventRepeat, "Пометка в Word: ${lesson.condition}")
if (date != null) NotesHint(LocalNotes.current.forLesson(lesson.subject, date, lesson.start), color.onSoft)
}
}
val border = when {
running -> BorderStroke(2.dp, color.onSoft)
session != null -> BorderStroke(2.dp, session.outline())
else -> null
}
if (onClick != null) Surface(
onClick = onClick,
shape = MaterialTheme.shapes.large,
color = color.soft,
contentColor = color.onSoft,
border = border,
modifier = Modifier.fillMaxWidth(),
content = content,
) else Surface(
shape = MaterialTheme.shapes.large,
color = color.soft,
contentColor = color.onSoft,
border = border,
modifier = Modifier.fillMaxWidth(),
content = content,
)
}
@Composable
private fun SoftTag(text: String, color: SubjectColor, icon: ImageVector? = null) {
Surface(shape = MaterialTheme.shapes.small, color = color.onSoft.copy(alpha = 0.12f), contentColor = color.onSoft) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = if (icon != null) 6.dp else 8.dp, end = 8.dp, top = 3.dp, bottom = 3.dp),
) {
icon?.let {
Icon(it, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(4.dp))
}
Text(text.replaceFirstChar { it.titlecase() }, style = MaterialTheme.typography.labelLarge)
}
}
}
@Composable
private fun Meta(icon: androidx.compose.ui.graphics.vector.ImageVector, text: String) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(icon, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(6.dp))
Text(text, style = MaterialTheme.typography.bodyMedium)
}
}
@Composable
private fun TypeTag(type: String) {
val scheme = MaterialTheme.colorScheme
val (bg, fg) = when (type) {
"Лекция" -> scheme.primaryContainer to scheme.onPrimaryContainer
"Практика" -> scheme.tertiaryContainer to scheme.onTertiaryContainer
"Экзамен", "Зачёт", "Защита курсовой работы" -> sessionColors().let { it.strong to it.onStrong }
"Консультация" -> sessionColors().let { it.soft to it.onSoft }
else -> scheme.secondaryContainer to scheme.onSecondaryContainer
}
Surface(shape = MaterialTheme.shapes.small, color = bg, contentColor = fg) {
Text(type.ifEmpty { "Занятие" }, style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp))
}
}
@Composable
private fun Tag(text: String, warning: Boolean = false) {
Surface(
shape = MaterialTheme.shapes.small,
color = if (warning) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.surfaceContainerHighest,
contentColor = if (warning) MaterialTheme.colorScheme.onErrorContainer else MaterialTheme.colorScheme.onSurfaceVariant,
) {
Text(text, style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp))
}
}
@Composable
internal fun LessonDetails(
lesson: Lesson,
bells: List<Bell>,
date: LocalDate? = null,
vm: MainViewModel? = null,
onClose: () -> Unit = {},
) {
val bell = bells.firstOrNull { it.start == lesson.start }
Column(
Modifier
.verticalScroll(rememberScrollState())
.padding(start = 24.dp, end = 24.dp, bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
TypeTag(lesson.type)
Row(verticalAlignment = Alignment.CenterVertically) {
SubjectDot(lesson.subject, 16.dp)
Spacer(Modifier.width(14.dp))
Heading(lesson.subject, style = MaterialTheme.typography.headlineSmallEmphasized)
}
Detail(
Icons.Rounded.Schedule,
bell?.let { "${it.pair} пара" } ?: "Время",
lesson.start + (lesson.end?.let { " — $it" } ?: ""),
bell?.halves?.takeIf { it.size > 1 }?.joinToString(", ") { it.joinToString("–") },
)
Detail(Icons.Rounded.Person, "Преподаватель", lesson.teachers.joinToString(", ").ifEmpty { "Не указан" })
Detail(
Icons.Rounded.LocationOn,
"Аудитория",
if (lesson.distance) "Дистанционно" else lesson.rooms.joinToString(", ").ifEmpty { "Не указана" },
if (lesson.roomMoved) "В этот день — перенос аудитории" else null,
)
Detail(Icons.Rounded.Groups, "Группы", lesson.groups.joinToString(", "), lesson.subgroup)
if (lesson.uncertain) Banner(
"Пометку «${lesson.condition}» не удалось сопоставить с таблицей недель. Сверьте с Word.",
offline = false,
onClose = null,
)
if (date != null && vm != null) {
LessonAttendance(vm, lesson, date)
LessonNotes(vm, lesson, date)
}
Heading("Исходная запись", style = MaterialTheme.typography.titleSmallEmphasized)
Surface(shape = MaterialTheme.shapes.large, color = MaterialTheme.colorScheme.surfaceContainerHigh) {
Text(lesson.raw, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(16.dp).fillMaxWidth())
}
if (vm != null) {
OutlinedButton(onClick = { vm.setHidden(lesson.hideKey, true); onClose() }) {
Icon(Icons.Rounded.VisibilityOff, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Скрыть такие занятия")
}
Text(
"«${lesson.subject}»${lesson.type.takeIf { it.isNotEmpty() }?.let { " · ${it.lowercase()}" }.orEmpty()} пропадёт из расписания, " +
"обзора, виджета и напоминаний во все недели. Вернуть можно в настройках.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
private fun Detail(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, value: String, extra: String? = null) {
Row {
Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 2.dp))
Spacer(Modifier.width(16.dp))
Column {
Text(label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(value, style = MaterialTheme.typography.bodyLargeEmphasized)
extra?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) }
}
}
}
/** When the week table has no semester for this schedule: the Word rows by weekday. */
@Composable
private fun WordRows(t: Timetable, subgroup: String?, hidden: Set<String>, onOpen: (Lesson) -> Unit) {
@@ -701,100 +538,12 @@ private fun WordRows(t: Timetable, subgroup: String?, hidden: Set<String>, onOpe
Heading(dayName(day.day), style = MaterialTheme.typography.headlineSmallEmphasized)
}
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)
if (places.isNotEmpty()) item(key = "p${day.day}") { DayPlaces(places) }
items(lessons, key = { "${day.day}-${it.key}" }) { lesson ->
LessonCard(lesson, time = lesson.start + (lesson.end?.let { " → $it" } ?: ""), onClick = { onOpen(lesson) })
}
}
}
}
@Composable
fun Banner(text: String, offline: Boolean, onClose: (() -> Unit)?, onRetry: (() -> Unit)? = null) {
Surface(
shape = MaterialTheme.shapes.large,
color = if (offline) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.errorContainer,
contentColor = if (offline) MaterialTheme.colorScheme.onSecondaryContainer else MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
) {
Row(Modifier.padding(start = 16.dp, end = 4.dp, top = 8.dp, bottom = 8.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(if (offline) Icons.Rounded.CloudOff else Icons.Rounded.ErrorOutline, contentDescription = null)
Spacer(Modifier.width(12.dp))
Text(text, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f).padding(vertical = 8.dp))
if (onRetry != null) TextButton(onClick = onRetry) { Text("Повторить") }
if (onClose != null) TextButton(onClick = onClose) { Text("OK") }
}
}
}
/**
* Centres an empty state in the space it is given — a phone screen or a tablet's pane —
* and scrolls when that space is too low, so the illustration is never squeezed.
*/
@Composable
fun CenteredEmpty(
modifier: Modifier = Modifier,
/** Room kept clear inside the scroll (under a FAB): content scrolls past it, never gets cut. */
bottomInset: Dp = 0.dp,
content: @Composable () -> Unit,
) {
BoxWithConstraints(modifier.fillMaxSize()) {
val height = maxHeight
Column(
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).heightIn(min = height).padding(bottom = bottomInset),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) { content() }
}
}
/**
* Empty state in the RideBus style: an icon on a seven-sided cookie shape, a title,
* a calm explanation and an optional tonal action.
*/
@Composable
fun Empty(
title: String,
text: String,
icon: ImageVector = Icons.Rounded.EventBusy,
action: Pair<String, () -> Unit>? = null,
) {
Column(
Modifier.fillMaxWidth().padding(vertical = 48.dp, horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(112.dp)
.background(MaterialTheme.colorScheme.primaryContainer, MaterialShapes.Cookie7Sided.toShape()),
) {
Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.onPrimaryContainer, modifier = Modifier.size(48.dp))
}
Spacer(Modifier.height(20.dp))
Text(title, style = MaterialTheme.typography.titleLargeEmphasized, textAlign = TextAlign.Center)
Spacer(Modifier.height(6.dp))
Text(
text,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
action?.let { (label, onClick) ->
Spacer(Modifier.height(20.dp))
FilledTonalButton(onClick = onClick) { Text(label) }
}
}
}
private val FACULTIES = mapOf(
"Финансово-экономический факультет" to "ФЭФ",
"Факультет информационных технологий" to "ФИТ",
"Факультет компьютерных наук и электроники" to "ФКНЭ",
"Гуманитарный факультет" to "ГФ",
"Инженерно-строительный факультет" to "ИСФ",
"Механико-технологический факультет" to "МТФ",
"Юридический факультет" to "ЮФ",
)
fun facultyShort(name: String): String? = name.takeIf { it.isNotEmpty() }?.let { FACULTIES[it] ?: it }

View File

@@ -1,4 +1,4 @@
package org.xtimms.psu.schedule.ui
package org.xtimms.psu.schedule.ui.schedule
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
@@ -37,7 +37,9 @@ import androidx.compose.ui.semantics.selected
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import org.xtimms.psu.schedule.data.DayPlace
import org.xtimms.psu.schedule.data.Lesson
import org.xtimms.psu.schedule.data.withPlace
import org.xtimms.psu.schedule.data.Week
import org.xtimms.psu.schedule.data.longLabel
import org.xtimms.psu.schedule.data.minutesLabel
@@ -47,6 +49,19 @@ import org.xtimms.psu.schedule.data.weekdayName
import java.time.Duration
import java.time.LocalDate
import java.time.LocalTime
import org.xtimms.psu.schedule.ui.components.Heading
import org.xtimms.psu.schedule.ui.components.TimeText
import org.xtimms.psu.schedule.ui.components.WeekMark
import org.xtimms.psu.schedule.ui.home.CompactLesson
import org.xtimms.psu.schedule.ui.lesson.LocalDisplay
import org.xtimms.psu.schedule.ui.lesson.SessionBadge
import org.xtimms.psu.schedule.ui.lesson.Slot
import org.xtimms.psu.schedule.ui.lesson.SubjectDot
import org.xtimms.psu.schedule.ui.lesson.session
import org.xtimms.psu.schedule.ui.lesson.subjectColor
import org.xtimms.psu.schedule.ui.motion.RollingText
import org.xtimms.psu.schedule.ui.motion.morphOrigin
import org.xtimms.psu.schedule.ui.motion.morphContent
// The schedule of a foldable half opened on a table, as a desk stand: above the fold, facing the
// student, one lesson large enough to read from a distance; below it, under the thumb, the bar,
@@ -65,6 +80,7 @@ fun StandDay(
date: LocalDate,
week: Week?,
slots: List<Slot>,
places: List<DayPlace>,
chosen: LocalTime?,
clock: LocalTime,
onOpen: (Lesson) -> Unit,
@@ -86,7 +102,11 @@ fun StandDay(
}
Text(
if (slots.isEmpty()) "Пар нет"
else "${slots.size} ${plural(slots.size, "пара", "пары", "пар")} · ${slots.first().start}–${slots.last().end}",
else listOfNotNull(
"${slots.size} ${plural(slots.size, "пара", "пары", "пар")} · ${slots.first().start}–${slots.last().end}",
// "Занятия в Полоцке", readable from across the desk with the day's span.
places.joinToString(", ") { listOfNotNull(it.text, it.subgroup?.lowercase()).joinToString(", ") }.ifEmpty { null },
).joinToString(" · "),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 12.dp),
@@ -124,9 +144,9 @@ private fun StandLesson(slot: Slot, clock: LocalTime, isToday: Boolean, onOpen:
shape = MaterialTheme.shapes.extraLarge,
color = color.soft,
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.morphContent("stand|${slot.date}|${lesson.key}|${lesson.start}").padding(24.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
TimeText(
"${slot.start} → ${slot.end}" + (lesson.pair?.let { " · $it пара" } ?: ""),
@@ -140,7 +160,7 @@ private fun StandLesson(slot: Slot, clock: LocalTime, isToday: Boolean, onOpen:
color = if (running) color.container else MaterialTheme.colorScheme.surface,
contentColor = if (running) color.content else MaterialTheme.colorScheme.onSurface,
) {
Text(
RollingText(
if (running) "Идёт · ещё ${minutesLabel(minutes)}" else "Через ${minutesLabel(minutes)}",
style = MaterialTheme.typography.labelLarge,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
@@ -158,14 +178,14 @@ private fun StandLesson(slot: Slot, clock: LocalTime, isToday: Boolean, onOpen:
overflow = TextOverflow.Ellipsis,
)
Row(horizontalArrangement = Arrangement.spacedBy(20.dp)) {
val room = if (lesson.distance) "Дистанционно" else lesson.rooms.joinToString(", ").takeIf { show.rooms && it.isNotEmpty() }?.let { "Ауд. $it" }
val room = if (lesson.distance) "Дистанционно" else withPlace(lesson.rooms.joinToString(", ").takeIf { show.rooms && it.isNotEmpty() }?.let { "Ауд. $it" }, lesson.place)
room?.let { StandMeta(Icons.Rounded.LocationOn, it) }
if (show.teachers && lesson.teachers.isNotEmpty()) StandMeta(Icons.Rounded.Person, lesson.teachers.joinToString(", "))
}
// Parallel subgroups share the slot: the others in a line.
slot.lessons.drop(1).forEach { other ->
Text(
listOfNotNull(other.subgroup, other.subject, other.rooms.joinToString(", ").takeIf { it.isNotEmpty() }?.let { "ауд. $it" })
listOfNotNull(other.subgroup, other.subject, withPlace(other.rooms.joinToString(", ").takeIf { it.isNotEmpty() }?.let { "ауд. $it" }, other.place))
.joinToString(" · "),
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
@@ -271,7 +291,8 @@ fun StandLessons(
color = if (past && !selected) MaterialTheme.colorScheme.onSurfaceVariant else Color.Unspecified,
)
val room = slot.lessons.flatMap { it.rooms }.distinct().joinToString(", ")
if (room.isNotEmpty()) Text("ауд. $room", style = MaterialTheme.typography.bodySmall, maxLines = 1)
withPlace(room.takeIf { it.isNotEmpty() }?.let { "ауд. $it" }, slot.lessons.firstNotNullOfOrNull { it.place })
?.let { Text(it, style = MaterialTheme.typography.bodySmall, maxLines = 1) }
}
}
}

View File

@@ -1,7 +1,9 @@
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
package org.xtimms.psu.schedule.ui
package org.xtimms.psu.schedule.ui.schedule
import androidx.compose.runtime.getValue
import androidx.compose.animation.core.tween
import androidx.compose.ui.text.intl.LocaleList
import androidx.compose.ui.text.style.Hyphens
import androidx.compose.ui.text.style.LineBreak
@@ -45,8 +47,30 @@ import org.xtimms.psu.schedule.data.longLabel
import org.xtimms.psu.schedule.data.shortWeekday
import org.xtimms.psu.schedule.data.today
import org.xtimms.psu.schedule.data.weekdayName
import org.xtimms.psu.schedule.data.withPlace
import java.time.LocalDate
import java.time.LocalTime
import org.xtimms.psu.schedule.ui.MainViewModel
import org.xtimms.psu.schedule.ui.components.navBarPadding
import org.xtimms.psu.schedule.ui.lesson.DayPlaceMark
import org.xtimms.psu.schedule.ui.lesson.LocalDisplay
import org.xtimms.psu.schedule.ui.lesson.SessionDot
import org.xtimms.psu.schedule.ui.lesson.Slot
import org.xtimms.psu.schedule.ui.lesson.abbreviate
import org.xtimms.psu.schedule.ui.lesson.outline
import org.xtimms.psu.schedule.ui.lesson.placesOn
import org.xtimms.psu.schedule.ui.lesson.rememberClock
import org.xtimms.psu.schedule.ui.lesson.session
import org.xtimms.psu.schedule.ui.lesson.slotsOn
import org.xtimms.psu.schedule.ui.lesson.subjectColor
import org.xtimms.psu.schedule.ui.motion.morphContent
import org.xtimms.psu.schedule.ui.motion.morphOrigin
import org.xtimms.psu.schedule.ui.notes.LocalNotes
import org.xtimms.psu.schedule.ui.lesson.forLesson
import org.xtimms.psu.schedule.ui.notes.forLesson
import org.xtimms.psu.schedule.ui.notes.open
import androidx.compose.ui.draw.clip
import org.xtimms.psu.schedule.ui.lesson.lessonPress
private val HOUR = 60.dp
private val GUTTER = 36.dp
@@ -88,6 +112,7 @@ fun WeekGrid(
) {
days.forEach { (d, _) ->
val isToday = d == today()
val places = vm.placesOn(t, d, subgroup)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
@@ -113,6 +138,8 @@ fun WeekGrid(
color = if (isToday) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface,
)
}
// "Занятия в Полоцке": the town of the whole day, under its date.
DayPlaceMark(places, Modifier.padding(top = 2.dp, start = 2.dp, end = 2.dp))
}
}
}
@@ -161,7 +188,8 @@ fun WeekGrid(
}
}
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(x = (-5).dp, y = y - 5.dp).size(10.dp).background(MaterialTheme.colorScheme.primary, CircleShape))
}
@@ -175,23 +203,26 @@ fun WeekGrid(
private fun LessonBlock(lesson: Lesson, slot: Slot, wide: Boolean, notes: Int, onClick: () -> Unit, modifier: Modifier) {
val color = subjectColor(lesson.subject)
val session = lesson.session
val morphKey = "grid|${slot.date}|${lesson.key}|${lesson.start}"
val origin = Modifier.morphOrigin(morphKey, color.container, 8.dp)
val rooms = lesson.rooms.joinToString(", ").takeIf { LocalDisplay.current.rooms }.orEmpty()
// A narrow column keeps the room whole: the day's town is under its date.
val room = when {
lesson.distance -> "дист."
LocalDisplay.current.rooms -> lesson.rooms.joinToString(", ")
else -> ""
wide -> withPlace(rooms, lesson.place).orEmpty()
else -> rooms.ifEmpty { lesson.place.orEmpty() }
}
Surface(
onClick = onClick,
shape = MaterialTheme.shapes.small,
color = color.container,
contentColor = color.content,
border = session?.let { androidx.compose.foundation.BorderStroke(2.dp, it.outline()) },
modifier = modifier.semantics {
modifier = modifier.then(origin).clip(MaterialTheme.shapes.small).lessonPress(lesson, slot.date, onClick).semantics {
contentDescription = "${lesson.subject}, ${slot.start}–${slot.end}" +
(if (room.isNotEmpty()) ", ауд. $room" else "") + (lesson.type.takeIf { it.isNotEmpty() }?.let { ", $it" } ?: "") +
(if (notes > 0) ", заданий и заметок: $notes" else "")
},
) {
) { Box(Modifier.fillMaxSize().morphContent(morphKey)) {
// A small dot in the corner marks lessons with open tasks or notes.
if (notes > 0) Box(Modifier.fillMaxSize().padding(5.dp), contentAlignment = Alignment.TopEnd) {
Box(Modifier.size(8.dp).background(color.content, CircleShape))
@@ -207,5 +238,5 @@ private fun LessonBlock(lesson: Lesson, slot: Slot, wide: Boolean, notes: Int, o
)
if (room.isNotEmpty()) Text(room, style = MaterialTheme.typography.labelSmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
} }
}

View File

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

View File

@@ -1,7 +1,9 @@
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
package org.xtimms.psu.schedule.ui
package org.xtimms.psu.schedule.ui.settings
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.draw.clip
import androidx.compose.foundation.selection.toggleable
import androidx.compose.ui.semantics.Role
@@ -43,7 +45,24 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.xtimms.psu.schedule.BuildConfig
import org.xtimms.psu.schedule.data.ThemeMode
import org.xtimms.psu.schedule.data.OPTION_PREFIX
import org.xtimms.psu.schedule.data.AUDIENCE_PREFIX
import org.xtimms.psu.schedule.notify.Reminders
import org.xtimms.psu.schedule.ui.MainViewModel
import org.xtimms.psu.schedule.ui.components.ConnectedChoice
import org.xtimms.psu.schedule.ui.components.ContentColumn
import org.xtimms.psu.schedule.ui.components.Heading
import org.xtimms.psu.schedule.ui.components.appBarColors
import org.xtimms.psu.schedule.ui.components.navBarPadding
import org.xtimms.psu.schedule.ui.home.ChangesSheet
import org.xtimms.psu.schedule.ui.home.changeDate
import org.xtimms.psu.schedule.ui.home.changesLabel
import org.xtimms.psu.schedule.ui.theme.LocalThemeReveal
import org.xtimms.psu.schedule.ui.theme.TapPoint
import org.xtimms.psu.schedule.ui.theme.rememberTapPoint
import org.xtimms.psu.schedule.ui.theme.tapPoint
import androidx.compose.runtime.rememberCoroutineScope
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -52,6 +71,14 @@ fun SettingsScreen(vm: MainViewModel, onBack: () -> Unit) {
val timetable by vm.timetable.collectAsStateWithLifecycle()
val changes by vm.changes.collectAsStateWithLifecycle()
var changesOpen by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(false) }
// A new theme spreads from the option or the switch that was tapped.
val reveal = LocalThemeReveal.current
val themeTap = rememberTapPoint()
val colorsTap = rememberTapPoint()
val scope = rememberCoroutineScope()
fun reveal(tap: TapPoint, apply: () -> Unit) {
scope.launch { reveal?.change(tap.at, apply) ?: apply() }
}
val s = settings ?: return
val context = LocalContext.current
val permission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
@@ -60,6 +87,20 @@ fun SettingsScreen(vm: MainViewModel, onBack: () -> Unit) {
val changesPermission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
vm.setChangeAlerts(granted)
}
// The day's alerts ask for the same permission; the switch that asked gets the answer.
var afterGrant by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf<((Boolean) -> Unit)?>(null) }
val dayPermission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
afterGrant?.invoke(granted)
afterGrant = null
}
/** A switch that needs notifications: asks first on Android 13+, then sets the setting. */
fun notifying(set: (Boolean) -> Unit): (Boolean) -> Unit = { on ->
if (on && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !Reminders.canNotify(context)) {
afterGrant = set
dayPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
} else set(on)
}
val allowed = Reminders.canNotify(context)
val scroll = TopAppBarDefaults.pinnedScrollBehavior()
Column(Modifier.fillMaxSize().nestedScroll(scroll.nestedScrollConnection)) {
@@ -82,15 +123,15 @@ fun SettingsScreen(vm: MainViewModel, onBack: () -> Unit) {
ConnectedChoice(
options = listOf(ThemeMode.System to "Авто", ThemeMode.Light to "Светлая", ThemeMode.Dark to "Тёмная"),
selected = s.theme,
onSelect = vm::setTheme,
modifier = Modifier.fillMaxWidth(),
onSelect = { mode -> if (mode != s.theme) reveal(themeTap) { vm.setTheme(mode) } },
modifier = Modifier.fillMaxWidth().tapPoint(themeTap),
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) SwitchRow(
"Цвета обоев",
"Цвета приложения подстраиваются под обои телефона",
s.dynamicColor,
vm::setDynamicColor,
)
Modifier.tapPoint(colorsTap),
) { on -> reveal(colorsTap) { vm.setDynamicColor(on) } }
}
Section("Расписание") {
val d = s.display
@@ -106,9 +147,12 @@ fun SettingsScreen(vm: MainViewModel, onBack: () -> Unit) {
SwitchRow("Пометки из Word", "Например, «3-13н.» — даты по ним уже рассчитаны", d.notes) {
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)
s.hidden.sorted().forEach { key ->
hidden.sorted().forEach { key ->
val (subject, type) = key.split("|", limit = 2).let { it[0] to it.getOrElse(1) { "" } }
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
@@ -170,6 +214,56 @@ fun SettingsScreen(vm: MainViewModel, onBack: () -> Unit) {
)
}
}
Section("Уведомления о дне") {
SwitchRow(
"Занятия в другом городе",
"Вечером накануне — «Завтра занятия в Полоцке» с первой парой и аудиторией. Если включены напоминания, о первой паре такого дня напомнит раньше: нужно время на дорогу.",
s.travelAlerts && allowed,
change = notifying(vm::setTravelAlerts),
)
if (s.travelAlerts && allowed) {
Text("Вечером в", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
ConnectedChoice(
options = listOf(18 * 60 to "18:00", 19 * 60 to "19:00", 20 * 60 to "20:00", 21 * 60 to "21:00"),
selected = s.travelTime,
onSelect = vm::setTravelTime,
modifier = Modifier.fillMaxWidth(),
mark = false,
)
if (s.reminders) {
Text("О первой паре — раньше на, мин", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
ConnectedChoice(
options = listOf(0 to "0", 15 to "15", 30 to "30", 45 to "45", 60 to "60"),
selected = s.travelLead,
onSelect = vm::setTravelLead,
modifier = Modifier.fillMaxWidth(),
mark = false,
)
}
}
SwitchRow(
"Переносы аудиторий",
"Утром в день переноса — какая пара и в какой аудитории, вместо обычной.",
s.roomAlerts && allowed,
change = notifying(vm::setRoomAlerts),
)
if (s.roomAlerts && allowed) {
Text("Утром в", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
ConnectedChoice(
options = listOf(6 * 60 + 30 to "6:30", 7 * 60 to "7:00", 7 * 60 + 30 to "7:30", 8 * 60 to "8:00"),
selected = s.roomTime,
onSelect = vm::setRoomTime,
modifier = Modifier.fillMaxWidth(),
mark = false,
)
}
SwitchRow(
"Текущая пара в шторке",
"Пока идёт пара — уведомление с оставшимся временем и следующей парой. На Android 16 оно видно в строке состояния и на экране блокировки.",
s.liveLesson && allowed,
change = notifying(vm::setLiveLesson),
)
}
Section("О расписании") {
val doc = timetable?.document
Info("Документ", doc?.name ?: "Группа не выбрана")
@@ -219,12 +313,17 @@ private fun Section(title: String, content: @Composable () -> Unit) {
}
@Composable
private fun SwitchRow(title: String, text: String, checked: Boolean, onChange: (Boolean) -> Unit) {
private fun SwitchRow(title: String, text: String, checked: Boolean, modifier: Modifier = Modifier, change: (Boolean) -> Unit) {
val haptic = LocalHapticFeedback.current
val onChange: (Boolean) -> Unit = { on ->
haptic.performHapticFeedback(if (on) HapticFeedbackType.ToggleOn else HapticFeedbackType.ToggleOff)
change(on)
}
// The whole row is the touch target, not only the switch.
Row(
verticalAlignment = Alignment.CenterVertically,
// The press highlight follows the row's rounded shape instead of a hard rectangle.
modifier = Modifier.fillMaxWidth().clip(MaterialTheme.shapes.medium).toggleable(value = checked, role = Role.Switch, onValueChange = onChange),
modifier = modifier.fillMaxWidth().clip(MaterialTheme.shapes.medium).toggleable(value = checked, role = Role.Switch, onValueChange = onChange),
) {
Column(Modifier.weight(1f).padding(end = 16.dp)) {
Text(title, style = MaterialTheme.typography.bodyLarge)

View File

@@ -1,6 +1,6 @@
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
package org.xtimms.psu.schedule.ui
package org.xtimms.psu.schedule.ui.subjects
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.staggeredgrid.items
@@ -12,6 +12,7 @@ import androidx.compose.material.icons.automirrored.rounded.Assignment
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material3.FilledTonalButton
import org.xtimms.psu.schedule.data.Note
import org.xtimms.psu.schedule.data.withPlace
import androidx.compose.foundation.background
import androidx.compose.material.icons.rounded.DateRange
import androidx.compose.material.icons.rounded.Event
@@ -40,7 +41,6 @@ import androidx.compose.material.icons.rounded.Person
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface
@@ -72,6 +72,34 @@ import org.xtimms.psu.schedule.data.shortWeekday
import org.xtimms.psu.schedule.data.today
import org.xtimms.psu.schedule.data.weekdayName
import java.time.LocalTime
import org.xtimms.psu.schedule.ui.AppNav
import org.xtimms.psu.schedule.ui.MainViewModel
import org.xtimms.psu.schedule.ui.MenuButton
import org.xtimms.psu.schedule.ui.components.CenteredEmpty
import org.xtimms.psu.schedule.ui.components.Empty
import org.xtimms.psu.schedule.ui.components.Heading
import org.xtimms.psu.schedule.ui.components.LocalUndo
import org.xtimms.psu.schedule.ui.components.SubjectsSkeleton
import org.xtimms.psu.schedule.ui.components.TimeText
import org.xtimms.psu.schedule.ui.components.UndoArea
import org.xtimms.psu.schedule.ui.components.appBarColors
import org.xtimms.psu.schedule.ui.components.navBarPadding
import org.xtimms.psu.schedule.ui.home.SectionCard
import org.xtimms.psu.schedule.ui.lesson.LocalDisplay
import org.xtimms.psu.schedule.ui.lesson.LocalMarks
import org.xtimms.psu.schedule.ui.lesson.Slot
import org.xtimms.psu.schedule.ui.lesson.SubjectDot
import org.xtimms.psu.schedule.ui.lesson.abbreviate
import org.xtimms.psu.schedule.ui.lesson.rememberClock
import org.xtimms.psu.schedule.ui.lesson.slotsOn
import org.xtimms.psu.schedule.ui.lesson.subjectColor
import org.xtimms.psu.schedule.ui.lesson.subjectKey
import org.xtimms.psu.schedule.ui.motion.AnimatedItem
import org.xtimms.psu.schedule.ui.notes.LocalNotes
import org.xtimms.psu.schedule.ui.notes.NoteEditor
import org.xtimms.psu.schedule.ui.notes.NoteRow
import org.xtimms.psu.schedule.ui.notes.NoteTarget
import org.xtimms.psu.schedule.ui.lesson.statsFor
/** Everything the loaded weeks say about one subject. */
private class SubjectSummary(
@@ -127,7 +155,7 @@ fun SubjectsScreen(vm: MainViewModel, nav: AppNav) {
)
val t = timetable
when {
t == null && status.loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { LoadingIndicator() }
t == null && status.loading -> SubjectsSkeleton()
t == null -> CenteredEmpty {
Empty("Предметов пока нет", "Выберите группу — предметы появятся из её расписания.", icon = Icons.AutoMirrored.Rounded.MenuBook, action = "Выбрать группу" to nav.openGroups)
}
@@ -283,7 +311,7 @@ private fun SubjectCard(s: SubjectSummary, onClick: () -> Unit) {
Fact(
Icons.Rounded.Event,
next?.let { (slot, lesson) ->
nextLabel(slot) + (lesson.rooms.takeIf { show.rooms && it.isNotEmpty() }?.let { ", ауд. ${it.joinToString(", ")}" } ?: "")
nextLabel(slot) + (withPlace(lesson.rooms.takeIf { show.rooms && it.isNotEmpty() }?.let { "ауд. ${it.joinToString(", ")}" }, lesson.place)?.let { ", $it" } ?: "")
} ?: "В загруженных неделях больше нет",
strong = next != null,
)
@@ -327,6 +355,8 @@ private fun Fact(icon: ImageVector, text: String, strong: Boolean = false) {
@Composable
private fun SubjectDetails(s: SubjectSummary, vm: MainViewModel) {
val color = subjectColor(s.name)
// A task deleted in the sheet offers "Вернуть" in the sheet itself.
UndoArea(bottom = false) {
Column(
Modifier
.verticalScroll(rememberScrollState())
@@ -360,7 +390,7 @@ private fun SubjectDetails(s: SubjectSummary, vm: MainViewModel) {
Text(
listOfNotNull(
lesson.type.ifEmpty { null },
if (lesson.distance) "дистанционно" else lesson.rooms.joinToString(", ").ifEmpty { null }?.let { "ауд. $it" },
if (lesson.distance) "дистанционно" else withPlace(lesson.rooms.joinToString(", ").ifEmpty { null }?.let { "ауд. $it" }, lesson.place),
lesson.teachers.joinToString(", ").ifEmpty { null },
).joinToString(" · "),
style = MaterialTheme.typography.bodyMedium,
@@ -371,6 +401,7 @@ private fun SubjectDetails(s: SubjectSummary, vm: MainViewModel) {
}
}
}
}
}
@Composable
@@ -388,6 +419,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. */
@Composable
private fun SubjectNotes(s: SubjectSummary, vm: MainViewModel) {
val undo = LocalUndo.current
val all by vm.notes.collectAsStateWithLifecycle()
val group = vm.currentGroupKey() ?: return
val notes = all.filter { subjectKey(it.subject) == subjectKey(s.name) }
@@ -402,7 +434,7 @@ private fun SubjectNotes(s: SubjectSummary, vm: MainViewModel) {
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
notes.forEach { note -> NoteRow(note, onToggle = { vm.toggleNote(note.id) }, onClick = { editing = note }, showSubject = false) }
notes.forEach { note -> NoteRow(note, onToggle = { vm.toggleNote(note.id) }, onClick = { editing = note }, showSubject = false, onPostpone = { vm.postponeNote(note, it) }) }
if (next != null) FilledTonalButton(onClick = { adding = true }) {
Icon(Icons.Rounded.Add, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
@@ -413,7 +445,7 @@ private fun SubjectNotes(s: SubjectSummary, vm: MainViewModel) {
editing = editing,
groupKey = editing?.group ?: group,
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 },
)
}

View File

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

View File

@@ -69,11 +69,13 @@ class NextLessonWidget : GlanceAppWidget() {
maxLines = 1,
style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 15.sp, fontWeight = FontWeight.Bold),
)
if (next.rooms.isNotEmpty()) Text(
"ауд. ${next.rooms}",
maxLines = 1,
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 13.sp),
)
next.where?.let { where ->
Text(
where,
maxLines = 1,
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 13.sp),
)
}
}
}
}

View File

@@ -34,6 +34,7 @@ import org.xtimms.psu.schedule.data.nowTime
import org.xtimms.psu.schedule.data.parseTime
import org.xtimms.psu.schedule.data.today
import org.xtimms.psu.schedule.data.weekdayName
import org.xtimms.psu.schedule.data.withPlace
import java.time.Duration
import java.time.LocalDate
@@ -71,8 +72,11 @@ private suspend fun state(context: Context): WidgetState {
"$start · через ${minutesLabel(Duration.between(now, begins).toMinutes())}"
else -> start + (l.pair?.let { " · $it пара" } ?: "")
}
val place = lessons.flatMap { it.rooms }.distinct().joinToString(", ")
label to (lessons.joinToString(" / ") { it.subject } + if (place.isNotEmpty()) " · ауд. $place" else "")
val place = withPlace(
lessons.flatMap { it.rooms }.distinct().joinToString(", ").takeIf { it.isNotEmpty() }?.let { "ауд. $it" },
lessons.firstNotNullOfOrNull { it.place },
)
label to (lessons.joinToString(" / ") { it.subject } + (place?.let { " · $it" } ?: ""))
}
return WidgetState(
timetable.group,

View File

@@ -8,6 +8,7 @@ import org.xtimms.psu.schedule.data.nowTime
import org.xtimms.psu.schedule.data.parseTime
import org.xtimms.psu.schedule.data.shortWeekday
import org.xtimms.psu.schedule.data.today
import org.xtimms.psu.schedule.data.withPlace
import java.time.Duration
import java.time.LocalDate
@@ -15,6 +16,8 @@ import java.time.LocalDate
data class Upcoming(val date: LocalDate, val start: String, val end: String?, val lessons: List<Lesson>, val running: Boolean) {
val subject get() = lessons.joinToString(" / ") { it.subject }
val rooms get() = lessons.flatMap { if (it.distance) listOf("дист.") else it.rooms }.distinct().joinToString(", ")
/** "ауд. 203Г · Полоцк": the rooms with the town the document names. */
val where get() = withPlace(rooms.takeIf { it.isNotEmpty() }?.let { "ауд. $it" }, lessons.firstNotNullOfOrNull { it.place })
/** "Идёт · ещё 40 мин", "Через 20 мин", "Завтра, 10:05" or "Пн, 10:05". */
fun whenLabel(): String {

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.lesson.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

@@ -13,8 +13,11 @@ import androidx.work.WorkManager
import androidx.work.WorkerParameters
import org.xtimms.psu.schedule.app
import org.xtimms.psu.schedule.notify.ChangeAlerts
import org.xtimms.psu.schedule.notify.DayAlerts
import org.xtimms.psu.schedule.notify.LiveLesson
import org.xtimms.psu.schedule.notify.Reminders
import org.xtimms.psu.schedule.widget.TodayWidget
import org.xtimms.psu.schedule.widget.WeekWidget
import java.util.concurrent.TimeUnit
/**
@@ -52,9 +55,14 @@ class SyncWorker(context: Context, params: WorkerParameters) : CoroutineWorker(c
/** Widget and reminders follow the cached timetable and settings. */
suspend fun afterChange(context: Context) {
val app = context.app
Reminders.reschedule(context, app.repository.timetable.value, app.settings.now())
val t = app.repository.timetable.value
val s = app.settings.now()
Reminders.reschedule(context, t, s)
DayAlerts.reschedule(context, t, s, app.repository)
LiveLesson.reschedule(context, t, s, app.repository)
TodayWidget().updateAll(context)
NextLessonWidget().updateAll(context)
WeekWidget().updateAll(context)
LessonTileService.refresh(context)
}
}
@@ -65,6 +73,7 @@ class WidgetTick(context: Context, params: WorkerParameters) : CoroutineWorker(c
override suspend fun doWork(): Result {
TodayWidget().updateAll(applicationContext)
NextLessonWidget().updateAll(applicationContext)
WeekWidget().updateAll(applicationContext)
LessonTileService.refresh(applicationContext)
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

@@ -1,18 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Material Symbols "calendar_month" in the adaptive icon safe zone. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="960"
android:viewportHeight="960">
<group
android:pivotX="480"
android:pivotY="480"
android:scaleX="0.5"
android:scaleY="0.5"
android:translateY="480">
<path
android:fillColor="#00497D"
android:pathData="M200,-80Q167,-80 143.5,-103.5Q120,-127 120,-160L120,-720Q120,-753 143.5,-776.5Q167,-800 200,-800L240,-800L240,-840Q240,-857 251.5,-868.5Q263,-880 280,-880Q297,-880 308.5,-868.5Q320,-857 320,-840L320,-800L640,-800L640,-840Q640,-857 651.5,-868.5Q663,-880 680,-880Q697,-880 708.5,-868.5Q720,-857 720,-840L720,-800L760,-800Q793,-800 816.5,-776.5Q840,-753 840,-720L840,-160Q840,-127 816.5,-103.5Q793,-80 760,-80L200,-80ZM200,-160L760,-160Q760,-160 760,-160Q760,-160 760,-160L760,-560L200,-560L200,-160Q200,-160 200,-160Q200,-160 200,-160ZM480,-400Q463,-400 451.5,-411.5Q440,-423 440,-440Q440,-457 451.5,-468.5Q463,-480 480,-480Q497,-480 508.5,-468.5Q520,-457 520,-440Q520,-423 508.5,-411.5Q497,-400 480,-400ZM320,-400Q303,-400 291.5,-411.5Q280,-423 280,-440Q280,-457 291.5,-468.5Q303,-480 320,-480Q337,-480 348.5,-468.5Q360,-457 360,-440Q360,-423 348.5,-411.5Q337,-400 320,-400ZM640,-400Q623,-400 611.5,-411.5Q600,-423 600,-440Q600,-457 611.5,-468.5Q623,-480 640,-480Q657,-480 668.5,-468.5Q680,-457 680,-440Q680,-423 668.5,-411.5Q657,-400 640,-400ZM480,-240Q463,-240 451.5,-251.5Q440,-263 440,-280Q440,-297 451.5,-308.5Q463,-320 480,-320Q497,-320 508.5,-308.5Q520,-297 520,-280Q520,-263 508.5,-251.5Q497,-240 480,-240ZM320,-240Q303,-240 291.5,-251.5Q280,-263 280,-280Q280,-297 291.5,-308.5Q303,-320 320,-320Q337,-320 348.5,-308.5Q360,-297 360,-280Q360,-263 348.5,-251.5Q337,-240 320,-240ZM640,-240Q623,-240 611.5,-251.5Q600,-263 600,-280Q600,-297 611.5,-308.5Q623,-320 640,-320Q657,-320 668.5,-308.5Q680,-297 680,-280Q680,-263 668.5,-251.5Q657,-240 640,-240Z" />
</group>
</vector>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Launcher shortcut: найти аудиторию. -->
<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="#FF0061A4"
android:pathData="M15.5,14h-0.79l-0.28,-0.27C15.41,12.59 16,11.11 16,9.5 16,5.91 13.09,3 9.5,3S3,5.91 3,9.5 5.91,16 9.5,16c1.61,0 3.09,-0.59 4.23,-1.57l0.27,0.28v0.79l5,4.99L20.49,19l-4.99,-5zM9.5,14C7.01,14 5,11.99 5,9.5S7.01,5 9.5,5 14,7.01 14,9.5 11.99,14 9.5,14z" />
</vector>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Launcher shortcut: новое задание. -->
<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="#FF0061A4"
android:pathData="M19,13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" />
</vector>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Launcher shortcut: сегодня. -->
<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="#FF0061A4"
android:pathData="M19,3h-1V1h-2v2H8V1H6v2H5c-1.11,0 -1.99,0.9 -1.99,2L3,19c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2V5c0,-1.1 -0.9,-2 -2,-2zM19,19H5V8h14v11zM7,10h5v5H7z" />
</vector>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Launcher shortcut: неделя. -->
<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="#FF0061A4"
android:pathData="M13,5h-2c-0.55,0 -1,0.45 -1,1v12c0,0.55 0.45,1 1,1h2c0.55,0 1,-0.45 1,-1V6c0,-0.55 -0.45,-1 -1,-1zM20,5h-2c-0.55,0 -1,0.45 -1,1v12c0,0.55 0.45,1 1,1h2c0.55,0 1,-0.45 1,-1V6c0,-0.55 -0.45,-1 -1,-1zM6,5H4c-0.55,0 -1,0.45 -1,1v12c0,0.55 0.45,1 1,1h2c0.55,0 1,-0.45 1,-1V6c0,-0.55 -0.45,-1 -1,-1z" />
</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

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
<background android:drawable="@mipmap/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 967 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

View File

@@ -5,4 +5,12 @@
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
</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>

View File

@@ -5,7 +5,19 @@
<string name="widget_description">Текущая и следующая пара вашей группы</string>
<string name="channel_reminders">Перед парой</string>
<string name="channel_changes">Изменения в расписании</string>
<string name="channel_day">Поездки и переносы</string>
<string name="channel_now">Текущая пара</string>
<string name="shortcut_today">Сегодня</string>
<string name="shortcut_today_long">Пары на сегодня</string>
<string name="shortcut_week">Неделя</string>
<string name="shortcut_week_long">Вся неделя</string>
<string name="shortcut_search">Найти аудиторию</string>
<string name="shortcut_search_long">Найти аудиторию</string>
<string name="shortcut_task">Новое задание</string>
<string name="shortcut_task_long">Новое задание</string>
<string name="next_widget_name">Следующая пара</string>
<string name="next_widget_description">Когда следующая пара, что это и в какой аудитории</string>
<string name="week_widget_name">Неделя</string>
<string name="week_widget_description">Пары каждого дня недели, дни в другом городе и переносы аудиторий</string>
<string name="tile_name">Пара</string>
</resources>

Some files were not shown because too many files have changed in this diff Show More