Compare commits
15 Commits
3055cbaf3c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 297091cb0e | |||
| 416527fb33 | |||
| b9c1f63301 | |||
| a3df53713f | |||
| 02e6404731 | |||
| c0487e4c2a | |||
| 10c85e3bb9 | |||
| 3d08d0cb70 | |||
| 67bb2ea6fb | |||
| 3ae7b38fca | |||
| de72d003b8 | |||
| e28d65310d | |||
| a95784df93 | |||
| 7becdb0902 | |||
| b9a8f91e1f |
@@ -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
@@ -6,3 +6,4 @@ dist/
|
||||
xcuserdata/
|
||||
DerivedData/
|
||||
*.xcuserstate
|
||||
.env
|
||||
|
||||
148
README.md
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)) }
|
||||
}
|
||||
|
||||
@@ -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 = "",
|
||||
|
||||
@@ -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 } }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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))
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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) {
|
||||
@@ -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()) {
|
||||
@@ -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,
|
||||
@@ -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
|
||||
@@ -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,
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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) {
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
) {
|
||||
@@ -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 }
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
} }
|
||||
}
|
||||
@@ -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) {
|
||||
@@ -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)
|
||||
@@ -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 },
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
16
android/app/src/main/res/animator/splash_calendar.xml
Normal 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>
|
||||
9
android/app/src/main/res/animator/splash_check.xml
Normal 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" />
|
||||
8
android/app/src/main/res/animator/splash_fade.xml
Normal 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" />
|
||||
9
android/app/src/main/res/animator/splash_rings.xml
Normal 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" />
|
||||
9
android/app/src/main/res/animator/splash_tabs.xml
Normal 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" />
|
||||
63
android/app/src/main/res/drawable-v31/splash_icon.xml
Normal 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>
|
||||
@@ -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>
|
||||
11
android/app/src/main/res/drawable/ic_shortcut_search.xml
Normal 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>
|
||||
11
android/app/src/main/res/drawable/ic_shortcut_task.xml
Normal 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>
|
||||
11
android/app/src/main/res/drawable/ic_shortcut_today.xml
Normal 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>
|
||||
11
android/app/src/main/res/drawable/ic_shortcut_week.xml
Normal 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>
|
||||
11
android/app/src/main/res/drawable/ic_widget_city.xml
Normal 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>
|
||||
11
android/app/src/main/res/drawable/ic_widget_move.xml
Normal 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>
|
||||
63
android/app/src/main/res/drawable/splash_icon.xml
Normal 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>
|
||||
11
android/app/src/main/res/drawable/splash_icon_animated.xml
Normal 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>
|
||||
@@ -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" />
|
||||
@@ -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>
|
||||
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 7.0 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png
Normal file
|
After Width: | Height: | Size: 7.4 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png
Normal file
|
After Width: | Height: | Size: 967 B |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 9.7 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||