feat: Add skeleton loading UI components for various screens
- Implemented `Skeleton.kt` for loading placeholders in the app, including lesson cards, schedule, overview, and subjects. - Introduced `Undo.kt` to manage undo actions with a snackbar interface. - Created `WeekWidget.kt` for a widget displaying the week's schedule, including dynamic updates based on the current timetable. - Added various splash animations in the `animator` directory for a smooth launch experience. - Designed drawable resources for the widget and splash screen, including icons for city and room moves. - Defined XML resources for widget configuration and photo paths.
This commit is contained in:
32
README.md
32
README.md
@@ -173,6 +173,23 @@ npm run sync
|
|||||||
- В документах «Введение в высшее образование» строка «г. Полоцк, Дом офицеров» даёт занятию город, а место проведения остаётся в тексте.
|
- В документах «Введение в высшее образование» строка «г. Полоцк, Дом офицеров» даёт занятию город, а место проведения остаётся в тексте.
|
||||||
- Какие заметки показать над днём группы, решает общий модуль `shared/places.js`: им пользуются и `/api/timetable`, и PWA. Одинаковая заметка во всех подгруппах сводится к одной для всей группы, а заметка своей подгруппы показывается как заметка всего дня.
|
- Какие заметки показать над днём группы, решает общий модуль `shared/places.js`: им пользуются и `/api/timetable`, и PWA. Одинаковая заметка во всех подгруппах сводится к одной для всей группы, а заметка своей подгруппы показывается как заметка всего дня.
|
||||||
|
|
||||||
|
**Занятия на выбор** (парсер 16). У 23‑, 24‑ и 25‑РГФ‑1 одна ячейка на всю группу содержит две пары сразу: «Немецкий язык (п.з) – Найверт Ю.О. 208А, / Французский язык (п.з.) – Глобенко М.В. 336Е». Это не подгруппы (ячейка не делится пополам): часть группы учит немецкий, часть французский. Так же записаны «Белорусский язык» и «Стилистика русского языка» у 26‑РГФ и две дисциплины по выбору у 23‑ИТ.
|
||||||
|
- Ячейка, где каждая строка — целая пара (предмет, вид в скобках, преподаватель), делится на отдельные занятия со своими преподавателем и аудиторией (`parallel` в `server/parser.js`). Строка без предмета («321Д») остаётся при паре над ней. До версии 16 такая ячейка была одной парой «Немецкий язык» с двумя преподавателями и аудиторией французского.
|
||||||
|
- Каждое такое занятие получает `option` — свой предмет, а документ — `choices` (`{groups, options}`). Тот же предмет в других ячейках этих групп («Немецкий язык» в четверг без французского) — тот же вариант и тоже получает `option`.
|
||||||
|
- `/api/timetable` (API 15) отдаёт у занятия `option`, а у группы — `choices`: `[["Немецкий язык", "Французский язык"]]`. Клиент, который о них не знает, просто показывает обе пары.
|
||||||
|
|
||||||
|
**Пары для иностранных студентов и для остальных** (парсер 17). «Русский язык как иностранный» и пары с пометкой «(для иностранных студентов)», «(для иностр.студ.)» — только для иностранцев, пары «(кроме иностр.студ.)» — только для остальных. Занятие получает `audience`: `"foreign"` или `"local"`.
|
||||||
|
- Пометка — не часть названия и аудитории: раньше «ауд.36н (для иностранных студентов)» давало аудиторию «36н(дляиностранныхстудентов)».
|
||||||
|
- В одной ячейке часто две пары сразу: иностранцы на РКИ, остальные на физкультуре или другом занятии. Такая ячейка делится на две, у каждой свой `audience`:
|
||||||
|
- «Ф и з и ч е с к а я к у л ь т у р а / Русский язык как иностранный (для иностранных студентов) …» (25‑ГЕО, 25‑КБ, 25‑ЭТ, 24‑СК, 24‑ЭБ);
|
||||||
|
- «Русский язык как иностранный (п.з.) Немирская В.А. 331Е ФИЗИЧЕСКАЯ КУЛЬТУРА» (25‑ и 26‑РГФ): раньше физкультура пропадала, а у РКИ не было аудитории;
|
||||||
|
- «3‑14н. Великая Отечественная война … 220Д / Русский язык как иностранный … 201Г»: раньше одна пара с аудиторией РКИ.
|
||||||
|
- «24‑КФ: Русский язык как иностранный …» в ячейке на 24‑КБ и 24‑КФ — занятие только 24‑КФ.
|
||||||
|
- Город или время строкой выше («17.00 Новополоцк») и преподаватель строкой ниже — не отдельная пара.
|
||||||
|
- Две пары, обе с пометкой («Теория языка (кроме иностр.студ.)» и «История языка (для иностр.студ.)» у 24‑РГФ‑1), — не выбор предмета, а пары для разных студентов.
|
||||||
|
- В группах иностранцев («25‑ДОи», «24‑ТНГи‑2») РКИ — пара для всех, `audience` у неё нет.
|
||||||
|
- `/api/timetable` отдаёт у занятия `audience`, а у группы — `audience: true`, если в ней есть такие пары.
|
||||||
|
|
||||||
Для старых `.doc` на сервере нужен LibreOffice Writer (`soffice` в PATH или `LIBREOFFICE_BIN`). Конвертация может изменить сложное форматирование, результат нужно сверить. Реальные документы выбранного источника — `.docx`; поддержка `.doc` проверяется отдельно на образце, преобразованном LibreOffice.
|
Для старых `.doc` на сервере нужен LibreOffice Writer (`soffice` в PATH или `LIBREOFFICE_BIN`). Конвертация может изменить сложное форматирование, результат нужно сверить. Реальные документы выбранного источника — `.docx`; поддержка `.doc` проверяется отдельно на образце, преобразованном LibreOffice.
|
||||||
|
|
||||||
PDF, Excel, изображения и сканы пока не импортируются. Они не выдаются за распознанное расписание.
|
PDF, Excel, изображения и сканы пока не импортируются. Они не выдаются за распознанное расписание.
|
||||||
@@ -244,9 +261,22 @@ DOMAIN=schedule.example.by docker compose --profile https up -d --build
|
|||||||
|
|
||||||
Проверено на эмуляторе Pixel 10 Pro Fold (`OPENED`, `HALF_OPENED` в портрете и в альбомной ориентации) и на AVD «6.7in Foldable» (флип: телефонный макет). Универсальный AVD флипа сообщает приложению сгиб всегда в состоянии `FLAT`, поэтому «ноутбук» на флипе проверен только на фолде.
|
Проверено на эмуляторе Pixel 10 Pro Fold (`OPENED`, `HALF_OPENED` в портрете и в альбомной ориентации) и на AVD «6.7in Foldable» (флип: телефонный макет). Универсальный AVD флипа сообщает приложению сгиб всегда в состоянии `FLAT`, поэтому «ноутбук» на флипе проверен только на фолде.
|
||||||
|
|
||||||
|
- **Запуск.** Заставка (`core-splashscreen`, одинаково на Android 8–16): на фоне приложения рисуется иконка — календарь вырастает, кольца опускаются, зелёная галочка дорисовывается (около 750 мс, на Android 12+ анимированно). Заставка держится, пока не прочитаны сохранённые настройки, поэтому пустого кадра нет. Потом иконка раздувается и растворяется, а карточки «Обзора» поднимаются одна за другой (один раз за запуск).
|
||||||
|
- **Отмена вместо подтверждений.** Удалённое задание или заметка и скрытая пара сразу пропадают, а снизу 10 секунд висит «Задание удалено · Вернуть» или «Скрыто: «…» · Вернуть». Фото удалённого задания стираются, только когда вернуть уже нельзя. В подробностях пары и в листе предмета snackbar показывается прямо в них.
|
||||||
|
- **Сегодня — на текущей паре.** Страница сегодняшнего дня открывается на идущей паре (или следующей), а не на первой утренней.
|
||||||
|
- **Виброотклик:** тик при выборе дня в ленте и при листании дней пальцем, при выборе в кнопках-переключателях. Подтверждение при выполнении задания и отметке посещения, щелчок у переключателей настроек.
|
||||||
|
- **Скелетоны вместо спиннера:** пока грузится первое расписание группы, «Обзор», «Расписание» и «Предметы» показывают серые заготовки в форме своих карточек с мягким бликом. Так же выглядят занятия преподавателя или аудитории в поиске и каталог групп. Для TalkBack это одно «Загрузка». При системной настройке «Убрать анимацию» блик, появление карточек и прочие украшения отключаются.
|
||||||
|
- **Карточка вырастает в подробности** (container transform). Пара открывается из своей карточки: из «Обзора», расписания по дням, недельной сетки, календаря «Планов» и подставки «ноутбука». Рамка плавно растягивается до полного экрана (на планшете — до большой панели по центру), картинка карточки растворяется, подробности проявляются. «Назад», предиктивный жест и касание за панелью сворачивают её обратно в карточку. Так же «+» в «Планах» и календаре раскрывается в лист нового задания; лист можно стянуть вниз за ручку.
|
||||||
|
- **Живая текущая пара.** Вокруг идущей пары по часовой стрелке от левого верхнего угла рисуется рамка: какая часть пары прошла. Она подрастает плавно, без скачков. Цифры обратного отсчёта («ещё 44 мин», «Через 15 мин») прокручиваются, как на табло. Линия «сейчас» в недельной сетке ползёт вниз вместе со временем.
|
||||||
|
- **Лента дней.** Выбранный день — один круг под всей лентой: при выборе другого дня он перетекает туда на пружинах, передний край спешит, задний догоняет, и по дороге круг вытягивается в каплю. В «Расписании» точка в круге дня значит «есть пары». Когда пролистываешь на другую неделю, её точки появляются по очереди, с понедельника.
|
||||||
|
- **Выполненное задание.** При отметке текст зачёркивается слева направо, строка за строкой, и задание чуть задерживается на месте, прежде чем уйти к выполненным. Когда закрыто последнее открытое, у «Всё сделано» выскакивает галочка.
|
||||||
|
- **Обновление жестом.** Потянуть вниз в «Обзоре» и в расписании: экспрессивный индикатор M3 с меняющейся формой и щелчок отклика, когда тянуть уже достаточно.
|
||||||
|
- **Свежие изменения на виду.** Если университет перезалил документ и пары изменились или добавились, их карточки в расписании дважды вспыхивают цветом (один раз, при первом показе).
|
||||||
- Первый запуск — короткий онбординг: приветствие с тремя возможностями приложения, затем шаги с волнистым индикатором прогресса: группа, подгруппа (только если группа делится на подгруппы) и напоминания. Разрешение на уведомления спрашивается на шаге напоминаний, его можно отложить; «Назад» (с предиктивным жестом) возвращает на предыдущий шаг. После онбординга открывается «Обзор» выбранной группы.
|
- Первый запуск — короткий онбординг: приветствие с тремя возможностями приложения, затем шаги с волнистым индикатором прогресса: группа, подгруппа (только если группа делится на подгруппы) и напоминания. Разрешение на уведомления спрашивается на шаге напоминаний, его можно отложить; «Назад» (с предиктивным жестом) возвращает на предыдущий шаг. После онбординга открывается «Обзор» выбранной группы.
|
||||||
- Обзор: отчёт за неделю (сколько пар прошло), следующие занятия сегодня с обратным отсчётом и ближайший учебный день.
|
- Обзор: отчёт за неделю (сколько пар прошло), следующие занятия сегодня с обратным отсчётом и ближайший учебный день.
|
||||||
- Расписание группы по дням с листанием или недельной сеткой по часам; неделя и её цвет, номера пар, текущая пара, детали занятия с исходной записью Word. Если семестр не найден в таблице недель — строки Word по дням недели.
|
- Расписание группы по дням с листанием или недельной сеткой по часам; неделя и её цвет, номера пар, текущая пара, детали занятия с исходной записью Word. Если семестр не найден в таблице недель — строки Word по дням недели.
|
||||||
|
- **Занятия на выбор** (немецкий или французский у РГФ). После выбора группы (и подгруппы) приложение спрашивает «Что вы изучаете?», в онбординге это отдельный шаг. Пары другого варианта скрываются из расписания, «Обзора», «Планов», виджетов, напоминаний и уведомлений. Изменить выбор можно в «Группах», в карточке «Сейчас открыта». Пока вариант не выбран, видны обе пары с меткой «на выбор».
|
||||||
|
- **Иностранные студенты.** В группах, где есть пары только для иностранцев («Русский язык как иностранный») или только для остальных, приложение спрашивает «Вы иностранный студент?» (и в онбординге). Ответ «Да» скрывает пары «кроме иностранных студентов», «Нет» — РКИ и другие пары для иностранцев. Изменить — в «Группах», строка «Иностранный студент». На карточке пары — метка «для иностранных студентов» или «кроме иностранных студентов».
|
||||||
- Предметы: пары по дням текущей недели; у каждого предмета — сокращение как в недельной сетке, следующее занятие с аудиторией, преподаватели и сколько пар на этой неделе. У каждого предмета свой постоянный цвет во всех разделах.
|
- Предметы: пары по дням текущей недели; у каждого предмета — сокращение как в недельной сетке, следующее занятие с аудиторией, преподаватели и сколько пар на этой неделе. У каждого предмета свой постоянный цвет во всех разделах.
|
||||||
- Планы: задания и заметки по дням — к занятию или общие, без привязки к паре, на выбранный день, просроченные сверху, «+» прикрепляет задание к ближайшему занятию. Три макета, как в School Planner: «Повестка дня» (все дни с липкими заголовками и полосой недели, которая следует за списком), «Список» (только дни с делами) и «Доска» (колонки дней). В настройках вида — пары дня и выполненные задания.
|
- Планы: задания и заметки по дням — к занятию или общие, без привязки к паре, на выбранный день, просроченные сверху, «+» прикрепляет задание к ближайшему занятию. Три макета, как в School Planner: «Повестка дня» (все дни с липкими заголовками и полосой недели, которая следует за списком), «Список» (только дни с делами) и «Доска» (колонки дней). В настройках вида — пары дня и выполненные задания.
|
||||||
- Календарь: месяц с отметками дней с парами и с планами, зелёные недели подсвечены; под ним лист с планами и парами выбранного дня. Лист можно поднять — месяц сворачивается до выбранной недели.
|
- Календарь: месяц с отметками дней с парами и с планами, зелёные недели подсвечены; под ним лист с планами и парами выбранного дня. Лист можно поднять — месяц сворачивается до выбранной недели.
|
||||||
@@ -255,9 +285,11 @@ DOMAIN=schedule.example.by docker compose --profile https up -d --build
|
|||||||
- **Группа, которой больше нет.** Если сервер отвечает `group_not_found` или `document_not_found` — группу разделили («24‑ИЯ(РА)» стала «24‑ИЯ (РА)и‑1»…), или документ ушёл из каталога, — плашка с текстом сервера предлагает «Выбрать группу». Сохранённая копия остаётся на экране, а устаревшая группа убирается из недавних.
|
- **Группа, которой больше нет.** Если сервер отвечает `group_not_found` или `document_not_found` — группу разделили («24‑ИЯ(РА)» стала «24‑ИЯ (РА)и‑1»…), или документ ушёл из каталога, — плашка с текстом сервера предлагает «Выбрать группу». Сохранённая копия остаётся на экране, а устаревшая группа убирается из недавних.
|
||||||
- **Изменения в расписании.** Как в PWA: когда университет перезаливает документ группы, приложение сравнивает новое расписание с копией на устройстве. В «Обзоре» появляется карточка «Расписание обновлено · N изменений» с кнопкой «Показать», а в списке видно, что изменилось, что добавлено и что удалено, например «Аудитория: 374 → 375». Карточка показывается до просмотра или закрытия, последний список остаётся в настройках («Последние изменения»). Сравнение срабатывает и при фоновом обновлении (`SyncWorker`, раз в 6 часов). Если изменения нашлись в фоне, приходит уведомление «Расписание 23-АРХ изменилось» с первыми изменениями, например «Проектирование общественных зданий: аудитория 374 → 375». Уведомление локальное, сервер push для него не нужен. Его можно выключить в настройках («Изменения в расписании», по умолчанию включено), а на Android 13+ нужно разрешение на уведомления. Считается только новая версия того же файла: смена версии парсера или переход на другой документ изменениями не считаются. Сравниваются строки Word по дням недели и разовые занятия со своей датой, а не каждая рассчитанная дата.
|
- **Изменения в расписании.** Как в PWA: когда университет перезаливает документ группы, приложение сравнивает новое расписание с копией на устройстве. В «Обзоре» появляется карточка «Расписание обновлено · N изменений» с кнопкой «Показать», а в списке видно, что изменилось, что добавлено и что удалено, например «Аудитория: 374 → 375». Карточка показывается до просмотра или закрытия, последний список остаётся в настройках («Последние изменения»). Сравнение срабатывает и при фоновом обновлении (`SyncWorker`, раз в 6 часов). Если изменения нашлись в фоне, приходит уведомление «Расписание 23-АРХ изменилось» с первыми изменениями, например «Проектирование общественных зданий: аудитория 374 → 375». Уведомление локальное, сервер push для него не нужен. Его можно выключить в настройках («Изменения в расписании», по умолчанию включено), а на Android 13+ нужно разрешение на уведомления. Считается только новая версия того же файла: смена версии парсера или переход на другой документ изменениями не считаются. Сравниваются строки Word по дням недели и разовые занятия со своей датой, а не каждая рассчитанная дата.
|
||||||
- Задания и заметки к паре: из карточки занятия — к этой паре или к следующей паре того же предмета. Задания отмечаются выполненными, просроченные подсвечиваются; список — в «Обзоре», по предмету — в «Предметах», точка на блоке в недельной сетке. Хранятся только на устройстве (`notes.json`) и привязаны к группе.
|
- Задания и заметки к паре: из карточки занятия — к этой паре или к следующей паре того же предмета. Задания отмечаются выполненными, просроченные подсвечиваются; список — в «Обзоре», по предмету — в «Предметах», точка на блоке в недельной сетке. Хранятся только на устройстве (`notes.json`) и привязаны к группе.
|
||||||
|
- **Фото к заданиям:** в редакторе задания или заметки — «Снять» (приложение камеры) и «Галерея» (системный выбор фото, без доступа к памяти), до 6 фото. Фото уменьшается до 2048 px по длинной стороне, поворачивается по EXIF и хранится в `files/photos/` рядом с заметками. Удаление фото, задания или отмена правки убирает файлы, а при запуске удаляются фото, на которые не ссылается ни одна заметка. В списках у задания миниатюра (с «+N»), на карточке пары — значок фото. Просмотр — на весь экран, с листанием, масштабом щипком и двойным касанием. Задание может быть и одним фото, без текста.
|
||||||
- Посещаемость: «был» или «пропустил» в карточке занятия и одним касанием в «Обзоре» для прошедших сегодня пар; статистика по каждому предмету в «Предметах». Хранится на устройстве (`attendance.json`).
|
- Посещаемость: «был» или «пропустил» в карточке занятия и одним касанием в «Обзоре» для прошедших сегодня пар; статистика по каждому предмету в «Предметах». Хранится на устройстве (`attendance.json`).
|
||||||
- Скрытие занятий: «Скрыть такие занятия» в карточке занятия убирает предмет с этим типом занятия из расписания, обзора, виджетов и напоминаний; вернуть можно в настройках.
|
- Скрытие занятий: «Скрыть такие занятия» в карточке занятия убирает предмет с этим типом занятия из расписания, обзора, виджетов и напоминаний; вернуть можно в настройках.
|
||||||
- Второй виджет «Следующая пара» (4×1) и плитка в шторке быстрых настроек с текущей или следующей парой и аудиторией.
|
- Второй виджет «Следующая пара» (4×1) и плитка в шторке быстрых настроек с текущей или следующей парой и аудиторией.
|
||||||
|
- **Виджет «Неделя»** (4×2 и больше): номер и цвет недели, по плитке на день (Пн–Сб, воскресенье — только если в нём есть пары) с числом пар. Сегодня выделено. Значок города — день в другом городе, значок стрелок — перенос аудитории, внизу строка вроде «Полоцк: вт, пт · перенос: пн, чт». Если растянуть виджет в высоту, в плитках появляются сокращения пар, как в недельной сетке. В воскресенье виджет показывает следующую неделю. Нажатие на день открывает этот день, на заголовок — неделю.
|
||||||
- Настройки отображения: цвета предметов, преподаватели, аудитории, номера пар, метки занятий и пометки из Word можно скрыть; полные сведения остаются в карточке занятия.
|
- Настройки отображения: цвета предметов, преподаватели, аудитории, номера пар, метки занятий и пометки из Word можно скрыть; полные сведения остаются в карточке занятия.
|
||||||
- Группы (кнопка группы в верхней панели «Обзора»; если у группы есть подгруппы, после выбора приложение предложит свою): поиск по номеру во всём каталоге, недавние, каталог по факультету и курсу, выбор подгруппы.
|
- Группы (кнопка группы в верхней панели «Обзора»; если у группы есть подгруппы, после выбора приложение предложит свою): поиск по номеру во всём каталоге, недавние, каталог по факультету и курсу, выбор подгруппы.
|
||||||
- Поиск по университету: занятия преподавателя или аудитории на выбранный день.
|
- Поиск по университету: занятия преподавателя или аудитории на выбранный день.
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ dependencies {
|
|||||||
implementation(libs.lifecycle.viewmodel.compose)
|
implementation(libs.lifecycle.viewmodel.compose)
|
||||||
implementation(libs.lifecycle.runtime.compose)
|
implementation(libs.lifecycle.runtime.compose)
|
||||||
implementation(libs.core.ktx)
|
implementation(libs.core.ktx)
|
||||||
|
implementation(libs.splashscreen)
|
||||||
implementation(libs.coroutines.android)
|
implementation(libs.coroutines.android)
|
||||||
implementation(libs.serialization.json)
|
implementation(libs.serialization.json)
|
||||||
implementation(libs.okhttp)
|
implementation(libs.okhttp)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,7 @@
|
|||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:launchMode="singleTop"
|
android:launchMode="singleTop"
|
||||||
|
android:theme="@style/Theme.PsuSchedule.Starting"
|
||||||
android:windowSoftInputMode="adjustResize">
|
android:windowSoftInputMode="adjustResize">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
@@ -58,6 +59,18 @@
|
|||||||
android:resource="@xml/next_widget_info" />
|
android:resource="@xml/next_widget_info" />
|
||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".widget.WeekWidgetReceiver"
|
||||||
|
android:exported="true"
|
||||||
|
android:label="@string/week_widget_name">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data
|
||||||
|
android:name="android.appwidget.provider"
|
||||||
|
android:resource="@xml/week_widget_info" />
|
||||||
|
</receiver>
|
||||||
|
|
||||||
<service
|
<service
|
||||||
android:name=".tile.LessonTileService"
|
android:name=".tile.LessonTileService"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
@@ -69,6 +82,17 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</service>
|
||||||
|
|
||||||
|
<!-- The camera app saves a photo for a task here (ui/Photos.kt). -->
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.photos"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/photo_paths" />
|
||||||
|
</provider>
|
||||||
|
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".notify.ReminderReceiver"
|
android:name=".notify.ReminderReceiver"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
|
|||||||
@@ -33,16 +33,20 @@ sealed interface Launch {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The intent of a target: "day" with its [date], "week", "today", "search" or "task". */
|
||||||
|
fun intent(context: Context, target: String, date: LocalDate? = null): Intent =
|
||||||
|
Intent(context, MainActivity::class.java)
|
||||||
|
.setAction(ACTION)
|
||||||
|
.putExtra(TARGET, target)
|
||||||
|
.apply { date?.let { putExtra(DATE, it.toString()) } }
|
||||||
|
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||||
|
|
||||||
/** Opens a day of the timetable, for a notification about it. */
|
/** Opens a day of the timetable, for a notification about it. */
|
||||||
fun day(context: Context, date: LocalDate, request: Int): PendingIntent =
|
fun day(context: Context, date: LocalDate, request: Int): PendingIntent =
|
||||||
PendingIntent.getActivity(
|
PendingIntent.getActivity(
|
||||||
context,
|
context,
|
||||||
request,
|
request,
|
||||||
Intent(context, MainActivity::class.java)
|
intent(context, "day", date),
|
||||||
.setAction(ACTION)
|
|
||||||
.putExtra(TARGET, "day")
|
|
||||||
.putExtra(DATE, date.toString())
|
|
||||||
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP),
|
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ import androidx.compose.foundation.gestures.draggable
|
|||||||
import androidx.compose.foundation.gestures.Orientation
|
import androidx.compose.foundation.gestures.Orientation
|
||||||
import kotlin.coroutines.cancellation.CancellationException
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||||
|
import org.xtimms.psu.schedule.ui.UndoArea
|
||||||
|
import org.xtimms.psu.schedule.ui.LocalMorph
|
||||||
|
import org.xtimms.psu.schedule.ui.MorphHost
|
||||||
|
import org.xtimms.psu.schedule.ui.MorphOverlay
|
||||||
import androidx.compose.animation.core.Animatable
|
import androidx.compose.animation.core.Animatable
|
||||||
import androidx.compose.animation.core.rememberTransition
|
import androidx.compose.animation.core.rememberTransition
|
||||||
import androidx.compose.animation.core.SeekableTransitionState
|
import androidx.compose.animation.core.SeekableTransitionState
|
||||||
@@ -160,6 +165,17 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
// The launch screen stays until the stored settings are read, so a returning student
|
||||||
|
// never sees an empty frame; then the icon swells and fades into the app.
|
||||||
|
val splash = installSplashScreen()
|
||||||
|
splash.setKeepOnScreenCondition { vm.settings.value == null }
|
||||||
|
splash.setOnExitAnimationListener { provider ->
|
||||||
|
val icon = provider.iconView
|
||||||
|
icon.animate().scaleX(1.15f).scaleY(1.15f).alpha(0f).setDuration(250L)
|
||||||
|
.setInterpolator(android.view.animation.PathInterpolator(0.3f, 0f, 0.8f, 0.15f)).start()
|
||||||
|
provider.view.animate().alpha(0f).setStartDelay(100L).setDuration(250L)
|
||||||
|
.withEndAction { provider.remove() }.start()
|
||||||
|
}
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
// A recreated activity (rotation) must not open the same screen again.
|
// A recreated activity (rotation) must not open the same screen again.
|
||||||
if (savedInstanceState == null) launch.value = Launch.of(intent)
|
if (savedInstanceState == null) launch.value = Launch.of(intent)
|
||||||
@@ -261,6 +277,10 @@ private fun App(vm: MainViewModel, firstRun: Boolean, launch: Launch?, onLaunche
|
|||||||
val slide = rememberSlideDistance()
|
val slide = rememberSlideDistance()
|
||||||
|
|
||||||
// Behind the pushed-aside screen: the menu's own card-white background.
|
// Behind the pushed-aside screen: the menu's own card-white background.
|
||||||
|
// "Задание удалено · Вернуть" and the like, over every screen (Undo.kt).
|
||||||
|
val morph = remember { MorphHost() }
|
||||||
|
UndoArea(Modifier.fillMaxSize()) {
|
||||||
|
androidx.compose.runtime.CompositionLocalProvider(LocalMorph provides morph) {
|
||||||
BoxWithConstraints(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.card)) {
|
BoxWithConstraints(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.card)) {
|
||||||
val wide = maxWidth >= MEDIUM_WIDTH
|
val wide = maxWidth >= MEDIUM_WIDTH
|
||||||
// An open book-style foldable gets the tablet layout; a flip phone keeps the phone one.
|
// An open book-style foldable gets the tablet layout; a flip phone keeps the phone one.
|
||||||
@@ -515,6 +535,10 @@ private fun App(vm: MainViewModel, firstRun: Boolean, launch: Launch?, onLaunche
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// A card's details and the "+" sheet open over everything, growing from what was tapped.
|
||||||
|
MorphOverlay(morph)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What the menu shows and where its items lead. */
|
/** What the menu shows and where its items lead. */
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package org.xtimms.psu.schedule
|
package org.xtimms.psu.schedule
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import org.xtimms.psu.schedule.data.Api
|
import org.xtimms.psu.schedule.data.Api
|
||||||
import org.xtimms.psu.schedule.data.AttendanceStore
|
import org.xtimms.psu.schedule.data.AttendanceStore
|
||||||
import org.xtimms.psu.schedule.data.NotesStore
|
import org.xtimms.psu.schedule.data.NotesStore
|
||||||
@@ -30,6 +31,8 @@ class ScheduleApp : Application() {
|
|||||||
org.xtimms.psu.schedule.notify.DayAlerts.createChannel(this)
|
org.xtimms.psu.schedule.notify.DayAlerts.createChannel(this)
|
||||||
org.xtimms.psu.schedule.notify.LiveLesson.createChannel(this)
|
org.xtimms.psu.schedule.notify.LiveLesson.createChannel(this)
|
||||||
SyncWorker.schedule(this)
|
SyncWorker.schedule(this)
|
||||||
|
// Before any editor opens: photos left by an edit that never got saved.
|
||||||
|
kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.IO).launch { notes.sweep() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ data class Timetable(
|
|||||||
val semester: Semester? = null,
|
val semester: Semester? = null,
|
||||||
val note: String? = null,
|
val note: String? = null,
|
||||||
val subgroups: List<String> = emptyList(),
|
val subgroups: List<String> = emptyList(),
|
||||||
|
/** What parts of the group choose between: [["Немецкий язык", "Французский язык"]]. */
|
||||||
|
val choices: List<List<String>> = emptyList(),
|
||||||
|
/** Some lessons are for foreign students only ([Lesson.audience]), so the student may say which they are. */
|
||||||
|
val audience: Boolean = false,
|
||||||
val bells: List<Bell> = emptyList(),
|
val bells: List<Bell> = emptyList(),
|
||||||
val from: String,
|
val from: String,
|
||||||
val days: List<Day> = emptyList(),
|
val days: List<Day> = emptyList(),
|
||||||
@@ -81,6 +85,10 @@ data class Lesson(
|
|||||||
val condition: String = "",
|
val condition: String = "",
|
||||||
val variant: String = "all",
|
val variant: String = "all",
|
||||||
val subgroup: String? = null,
|
val subgroup: String? = null,
|
||||||
|
/** "Немецкий язык": the lesson of the part of the group that took this option (see [Timetable.choices]). */
|
||||||
|
val option: String? = null,
|
||||||
|
/** "foreign": for foreign students only (Russian as a foreign language); "local": for the others. */
|
||||||
|
val audience: String? = null,
|
||||||
val groups: List<String> = emptyList(),
|
val groups: List<String> = emptyList(),
|
||||||
val uncertain: Boolean = false,
|
val uncertain: Boolean = false,
|
||||||
val raw: String = "",
|
val raw: String = "",
|
||||||
@@ -89,6 +97,24 @@ data class Lesson(
|
|||||||
/** A recurring lesson the student may hide: the subject with its kind, in every week. */
|
/** A recurring lesson the student may hide: the subject with its kind, in every week. */
|
||||||
val Lesson.hideKey: String get() = subject.trim().lowercase() + "|" + type.trim().lowercase()
|
val Lesson.hideKey: String get() = subject.trim().lowercase() + "|" + type.trim().lowercase()
|
||||||
|
|
||||||
|
/** How a choice's option the student did not take sits among the hidden lessons. */
|
||||||
|
const val OPTION_PREFIX = "option|"
|
||||||
|
|
||||||
|
fun optionKey(option: String): String = OPTION_PREFIX + option.trim().lowercase()
|
||||||
|
|
||||||
|
/** How the lessons for foreign students, or for the others, sit among the hidden ones. */
|
||||||
|
const val AUDIENCE_PREFIX = "audience|"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hidden by the student, the lesson of another option than theirs (German when they study
|
||||||
|
* French), or one for foreign students when they are not (and the other way round).
|
||||||
|
*/
|
||||||
|
fun Lesson.hiddenIn(hidden: Set<String>): Boolean =
|
||||||
|
hideKey in hidden || (option != null && optionKey(option) in hidden) || (audience != null && AUDIENCE_PREFIX + audience in hidden)
|
||||||
|
|
||||||
|
/** One choice of a group, the same whatever order the document lists its options in. */
|
||||||
|
fun choiceKey(options: List<String>): String = options.map { it.trim().lowercase() }.sorted().joinToString("|")
|
||||||
|
|
||||||
/** A room label with the lesson's town when the document names one: "ауд. 203Г · Полоцк", or the town alone. */
|
/** A room label with the lesson's town when the document names one: "ауд. 203Г · Полоцк", or the town alone. */
|
||||||
fun withPlace(room: String?, place: String?): String? =
|
fun withPlace(room: String?, place: String?): String? =
|
||||||
listOfNotNull(room?.takeIf { it.isNotEmpty() }, place).joinToString(" · ").ifEmpty { null }
|
listOfNotNull(room?.takeIf { it.isNotEmpty() }, place).joinToString(" · ").ifEmpty { null }
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
package org.xtimms.psu.schedule.data
|
package org.xtimms.psu.schedule.data
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.graphics.Matrix
|
||||||
|
import android.media.ExifInterface
|
||||||
|
import android.net.Uri
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -27,13 +32,16 @@ data class Note(
|
|||||||
val task: Boolean = true,
|
val task: Boolean = true,
|
||||||
val done: Boolean = false,
|
val done: Boolean = false,
|
||||||
val created: Long = System.currentTimeMillis(),
|
val created: Long = System.currentTimeMillis(),
|
||||||
|
/** Photos of the board or the assignment: file names in [NotesStore.photos]. */
|
||||||
|
val photos: List<String> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
fun groupKey(g: SavedGroup) = "${g.docId}|${g.group}"
|
fun groupKey(g: SavedGroup) = "${g.docId}|${g.group}"
|
||||||
|
|
||||||
/** Notes live only on the device, in one small JSON file. */
|
/** Notes live only on the device, in one small JSON file; their photos in a folder beside it. */
|
||||||
class NotesStore(context: Context) {
|
class NotesStore(private val context: Context) {
|
||||||
private val file = File(context.filesDir, "notes.json")
|
private val file = File(context.filesDir, "notes.json")
|
||||||
|
val photos = File(context.filesDir, "photos")
|
||||||
private val lock = Mutex()
|
private val lock = Mutex()
|
||||||
private val _notes = MutableStateFlow(read())
|
private val _notes = MutableStateFlow(read())
|
||||||
val notes: StateFlow<List<Note>> = _notes
|
val notes: StateFlow<List<Note>> = _notes
|
||||||
@@ -50,10 +58,77 @@ class NotesStore(context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun save(note: Note) = change { list ->
|
suspend fun save(note: Note) = change { list ->
|
||||||
|
// Photos taken out of the note go from the disk too.
|
||||||
|
list.firstOrNull { it.id == note.id }?.let { discard(it.photos - note.photos.toSet()) }
|
||||||
if (list.any { it.id == note.id }) list.map { if (it.id == note.id) note else it } else list + note
|
if (list.any { it.id == note.id }) list.map { if (it.id == note.id) note else it } else list + note
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun delete(id: String) = change { list -> list.filterNot { it.id == id } }
|
suspend fun delete(id: String) = change { list ->
|
||||||
|
list.firstOrNull { it.id == id }?.let { discard(it.photos) }
|
||||||
|
list.filterNot { it.id == id }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Takes a note out of the list but keeps its photos, so "Вернуть" can bring it back whole. */
|
||||||
|
suspend fun remove(id: String): Note? {
|
||||||
|
var removed: Note? = null
|
||||||
|
change { list ->
|
||||||
|
removed = list.firstOrNull { it.id == id }
|
||||||
|
list.filterNot { it.id == id }
|
||||||
|
}
|
||||||
|
return removed
|
||||||
|
}
|
||||||
|
|
||||||
|
fun photo(name: String) = File(photos, name)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A picked or taken picture, kept as a JPEG of at most [MAX_SIDE] px on its long side and
|
||||||
|
* turned upright: a phone photo of a board is a few hundred kilobytes, not several megabytes.
|
||||||
|
* Returns the file name, or null when the picture cannot be read.
|
||||||
|
*/
|
||||||
|
suspend fun importPhoto(uri: Uri): String? = withContext(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
val resolver = context.contentResolver
|
||||||
|
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||||
|
resolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, bounds) }
|
||||||
|
var sample = 1
|
||||||
|
while (maxOf(bounds.outWidth, bounds.outHeight) / (sample * 2) >= MAX_SIDE) sample *= 2
|
||||||
|
val decoded = resolver.openInputStream(uri)?.use {
|
||||||
|
BitmapFactory.decodeStream(it, null, BitmapFactory.Options().apply { inSampleSize = sample })
|
||||||
|
} ?: return@runCatching null
|
||||||
|
val rotation = resolver.openInputStream(uri)?.use {
|
||||||
|
when (ExifInterface(it).getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)) {
|
||||||
|
ExifInterface.ORIENTATION_ROTATE_90 -> 90f
|
||||||
|
ExifInterface.ORIENTATION_ROTATE_180 -> 180f
|
||||||
|
ExifInterface.ORIENTATION_ROTATE_270 -> 270f
|
||||||
|
else -> 0f
|
||||||
|
}
|
||||||
|
} ?: 0f
|
||||||
|
val scale = minOf(1f, MAX_SIDE.toFloat() / maxOf(decoded.width, decoded.height))
|
||||||
|
val matrix = Matrix().apply { postScale(scale, scale); postRotate(rotation) }
|
||||||
|
val upright = Bitmap.createBitmap(decoded, 0, 0, decoded.width, decoded.height, matrix, true)
|
||||||
|
photos.mkdirs()
|
||||||
|
val name = UUID.randomUUID().toString() + ".jpg"
|
||||||
|
photo(name).outputStream().use { upright.compress(Bitmap.CompressFormat.JPEG, 85, it) }
|
||||||
|
name
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Removes photo files: of a deleted note, or added to a note that was never saved. */
|
||||||
|
fun discard(names: Collection<String>) {
|
||||||
|
names.forEach { photo(it).delete() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Photos no note refers to any more, after a crash mid-edit, for example. */
|
||||||
|
suspend fun sweep() = withContext(Dispatchers.IO) {
|
||||||
|
lock.withLock {
|
||||||
|
val used = _notes.value.flatMap { it.photos }.toSet()
|
||||||
|
photos.listFiles()?.filter { it.name !in used }?.forEach { it.delete() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val MAX_SIDE = 2048
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun toggle(id: String) = change { list -> list.map { if (it.id == id) it.copy(done = !it.done) else it } }
|
suspend fun toggle(id: String) = change { list -> list.map { if (it.id == id) it.copy(done = !it.done) else it } }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,13 @@ class Repository(context: Context, val api: Api, private val settings: Settings)
|
|||||||
private val _changes = MutableStateFlow(readChanges())
|
private val _changes = MutableStateFlow(readChanges())
|
||||||
val changes: StateFlow<List<ScheduleChanges>> = _changes
|
val changes: StateFlow<List<ScheduleChanges>> = _changes
|
||||||
|
|
||||||
|
// Lessons just added or changed by a republished document: their cards glow once when shown.
|
||||||
|
private val _fresh = MutableStateFlow<Set<String>>(emptySet())
|
||||||
|
val fresh: StateFlow<Set<String>> = _fresh
|
||||||
|
fun freshShown(key: String) {
|
||||||
|
_fresh.value = _fresh.value - key
|
||||||
|
}
|
||||||
|
|
||||||
private fun readChanges(): List<ScheduleChanges> =
|
private fun readChanges(): List<ScheduleChanges> =
|
||||||
runCatching { json.decodeFromString<List<ScheduleChanges>>(changesCache.readText()) }.getOrDefault(emptyList())
|
runCatching { json.decodeFromString<List<ScheduleChanges>>(changesCache.readText()) }.getOrDefault(emptyList())
|
||||||
|
|
||||||
@@ -58,6 +65,7 @@ class Repository(context: Context, val api: Api, private val settings: Settings)
|
|||||||
changed = changed,
|
changed = changed,
|
||||||
)
|
)
|
||||||
writeChanges((listOf(record) + _changes.value.filter { it.document != record.document || it.group != record.group }).take(12))
|
writeChanges((listOf(record) + _changes.value.filter { it.document != record.document || it.group != record.group }).take(12))
|
||||||
|
_fresh.value = (added.map { it.lesson.key } + changed.map { it.after.lesson.key }).toSet()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The overview's card was opened or closed: the record stays for "Последние изменения". */
|
/** The overview's card was opened or closed: the record stays for "Последние изменения". */
|
||||||
@@ -110,7 +118,7 @@ class Repository(context: Context, val api: Api, private val settings: Settings)
|
|||||||
fun lessonsOn(timetable: Timetable, date: LocalDate, subgroup: String?, hidden: Set<String> = emptySet()): List<Lesson> =
|
fun lessonsOn(timetable: Timetable, date: LocalDate, subgroup: String?, hidden: Set<String> = emptySet()): List<Lesson> =
|
||||||
timetable.days.firstOrNull { it.date == date.toString() }?.lessons.orEmpty()
|
timetable.days.firstOrNull { it.date == date.toString() }?.lessons.orEmpty()
|
||||||
.filter { subgroup == null || it.subgroup == null || it.subgroup == subgroup }
|
.filter { subgroup == null || it.subgroup == null || it.subgroup == subgroup }
|
||||||
.filter { it.hideKey !in hidden }
|
.filter { !it.hiddenIn(hidden) }
|
||||||
|
|
||||||
fun dayOf(timetable: Timetable, date: LocalDate): Day? =
|
fun dayOf(timetable: Timetable, date: LocalDate): Day? =
|
||||||
timetable.days.firstOrNull { it.date == date.toString() }
|
timetable.days.firstOrNull { it.date == date.toString() }
|
||||||
|
|||||||
@@ -58,8 +58,15 @@ data class UserSettings(
|
|||||||
val liveLesson: Boolean = false,
|
val liveLesson: Boolean = false,
|
||||||
val recent: List<SavedGroup> = emptyList(),
|
val recent: List<SavedGroup> = emptyList(),
|
||||||
val display: Display = Display(),
|
val display: Display = Display(),
|
||||||
/** Lessons hidden in the open group, as [Lesson.hideKey]s. */
|
/**
|
||||||
|
* Lessons hidden in the open group, as [Lesson.hideKey]s, and the options of its choices the
|
||||||
|
* student did not take, as [optionKey]s.
|
||||||
|
*/
|
||||||
val hidden: Set<String> = emptySet(),
|
val hidden: Set<String> = emptySet(),
|
||||||
|
/** The open group's choices ([choiceKey]) and the option taken in each: "Французский язык". */
|
||||||
|
val choices: Map<String, String> = emptyMap(),
|
||||||
|
/** Whether the student is a foreign student in the open group; null: show every lesson. */
|
||||||
|
val foreign: Boolean? = null,
|
||||||
val plans: PlansPrefs = PlansPrefs(),
|
val plans: PlansPrefs = PlansPrefs(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -70,6 +77,8 @@ class Settings(private val context: Context) {
|
|||||||
private object Keys {
|
private object Keys {
|
||||||
val current = stringPreferencesKey("current")
|
val current = stringPreferencesKey("current")
|
||||||
val subgroups = stringPreferencesKey("subgroups")
|
val subgroups = stringPreferencesKey("subgroups")
|
||||||
|
val choices = stringPreferencesKey("choices")
|
||||||
|
val foreign = stringPreferencesKey("foreign")
|
||||||
val theme = stringPreferencesKey("theme")
|
val theme = stringPreferencesKey("theme")
|
||||||
val dynamic = booleanPreferencesKey("dynamic_color")
|
val dynamic = booleanPreferencesKey("dynamic_color")
|
||||||
val reminders = booleanPreferencesKey("reminders")
|
val reminders = booleanPreferencesKey("reminders")
|
||||||
@@ -94,6 +103,12 @@ class Settings(private val context: Context) {
|
|||||||
val plansDone = booleanPreferencesKey("plans_done")
|
val plansDone = booleanPreferencesKey("plans_done")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun Preferences.choiceMap(): Map<String, Map<String, String>> =
|
||||||
|
this[Keys.choices]?.let { runCatching { json.decodeFromString<Map<String, Map<String, String>>>(it) }.getOrNull() }.orEmpty()
|
||||||
|
|
||||||
|
private fun Preferences.foreignMap(): Map<String, Boolean> =
|
||||||
|
this[Keys.foreign]?.let { runCatching { json.decodeFromString<Map<String, Boolean>>(it) }.getOrNull() }.orEmpty()
|
||||||
|
|
||||||
private fun Preferences.hiddenMap(): Map<String, List<String>> =
|
private fun Preferences.hiddenMap(): Map<String, List<String>> =
|
||||||
this[Keys.hidden]?.let { runCatching { json.decodeFromString<Map<String, List<String>>>(it) }.getOrNull() }.orEmpty()
|
this[Keys.hidden]?.let { runCatching { json.decodeFromString<Map<String, List<String>>>(it) }.getOrNull() }.orEmpty()
|
||||||
|
|
||||||
@@ -106,6 +121,14 @@ class Settings(private val context: Context) {
|
|||||||
val subgroups = this[Keys.subgroups]?.let {
|
val subgroups = this[Keys.subgroups]?.let {
|
||||||
runCatching { json.decodeFromString<Map<String, String>>(it) }.getOrNull()
|
runCatching { json.decodeFromString<Map<String, String>>(it) }.getOrNull()
|
||||||
}.orEmpty()
|
}.orEmpty()
|
||||||
|
val choices = current?.let { choiceMap()[subgroupKey(it)] }.orEmpty()
|
||||||
|
val foreign = current?.let { foreignMap()[subgroupKey(it)] }
|
||||||
|
// A foreign student does not see the others' lessons, and the other way round.
|
||||||
|
val audience = when (foreign) {
|
||||||
|
true -> setOf(AUDIENCE_PREFIX + "local")
|
||||||
|
false -> setOf(AUDIENCE_PREFIX + "foreign")
|
||||||
|
null -> emptySet()
|
||||||
|
}
|
||||||
return UserSettings(
|
return UserSettings(
|
||||||
current = current,
|
current = current,
|
||||||
subgroup = current?.let { subgroups[subgroupKey(it)] },
|
subgroup = current?.let { subgroups[subgroupKey(it)] },
|
||||||
@@ -123,7 +146,9 @@ class Settings(private val context: Context) {
|
|||||||
recent = this[Keys.recent]?.let {
|
recent = this[Keys.recent]?.let {
|
||||||
runCatching { json.decodeFromString<List<SavedGroup>>(it) }.getOrNull()
|
runCatching { json.decodeFromString<List<SavedGroup>>(it) }.getOrNull()
|
||||||
}.orEmpty(),
|
}.orEmpty(),
|
||||||
hidden = current?.let { hiddenMap()[subgroupKey(it)] }.orEmpty().toSet(),
|
hidden = current?.let { hiddenMap()[subgroupKey(it)] }.orEmpty().toSet() + skipped(choices) + audience,
|
||||||
|
choices = choices,
|
||||||
|
foreign = foreign,
|
||||||
plans = PlansPrefs(
|
plans = PlansPrefs(
|
||||||
layout = this[Keys.plansLayout]?.let { runCatching { PlansLayout.valueOf(it) }.getOrNull() } ?: PlansLayout.Agenda,
|
layout = this[Keys.plansLayout]?.let { runCatching { PlansLayout.valueOf(it) }.getOrNull() } ?: PlansLayout.Agenda,
|
||||||
lessons = this[Keys.plansLessons] ?: false,
|
lessons = this[Keys.plansLessons] ?: false,
|
||||||
@@ -142,6 +167,10 @@ class Settings(private val context: Context) {
|
|||||||
|
|
||||||
private fun subgroupKey(g: SavedGroup) = "${g.docId}|${g.group}"
|
private fun subgroupKey(g: SavedGroup) = "${g.docId}|${g.group}"
|
||||||
|
|
||||||
|
/** The options not taken: their lessons are hidden like the ones the student hid. */
|
||||||
|
private fun skipped(choices: Map<String, String>): Set<String> =
|
||||||
|
choices.flatMap { (key, taken) -> key.split("|").filter { it != taken.trim().lowercase() }.map(::optionKey) }.toSet()
|
||||||
|
|
||||||
suspend fun openGroup(group: SavedGroup) = context.store.edit { prefs ->
|
suspend fun openGroup(group: SavedGroup) = context.store.edit { prefs ->
|
||||||
prefs[Keys.current] = json.encodeToString(group)
|
prefs[Keys.current] = json.encodeToString(group)
|
||||||
val recent = prefs[Keys.recent]?.let {
|
val recent = prefs[Keys.recent]?.let {
|
||||||
@@ -169,6 +198,26 @@ class Settings(private val context: Context) {
|
|||||||
prefs[Keys.subgroups] = json.encodeToString(map.toMap())
|
prefs[Keys.subgroups] = json.encodeToString(map.toMap())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The option the student takes in a choice of the open group ([option] null: all of them). */
|
||||||
|
suspend fun setChoice(options: List<String>, option: String?) = context.store.edit { prefs ->
|
||||||
|
val current = prefs[Keys.current]?.let { runCatching { json.decodeFromString<SavedGroup>(it) }.getOrNull() }
|
||||||
|
?: return@edit
|
||||||
|
val all = prefs.choiceMap().toMutableMap()
|
||||||
|
val mine = all[subgroupKey(current)].orEmpty().toMutableMap()
|
||||||
|
if (option == null) mine.remove(choiceKey(options)) else mine[choiceKey(options)] = option
|
||||||
|
all[subgroupKey(current)] = mine
|
||||||
|
prefs[Keys.choices] = json.encodeToString(all.toMap())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the student is a foreign student in the open group; null forgets the answer. */
|
||||||
|
suspend fun setForeign(foreign: Boolean?) = context.store.edit { prefs ->
|
||||||
|
val current = prefs[Keys.current]?.let { runCatching { json.decodeFromString<SavedGroup>(it) }.getOrNull() }
|
||||||
|
?: return@edit
|
||||||
|
val map = prefs.foreignMap().toMutableMap()
|
||||||
|
if (foreign == null) map.remove(subgroupKey(current)) else map[subgroupKey(current)] = foreign
|
||||||
|
prefs[Keys.foreign] = json.encodeToString(map.toMap())
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun setTheme(mode: ThemeMode) = context.store.edit { it[Keys.theme] = mode.name }
|
suspend fun setTheme(mode: ThemeMode) = context.store.edit { it[Keys.theme] = mode.name }
|
||||||
suspend fun setDynamicColor(on: Boolean) = context.store.edit { it[Keys.dynamic] = on }
|
suspend fun setDynamicColor(on: Boolean) = context.store.edit { it[Keys.dynamic] = on }
|
||||||
suspend fun setReminders(on: Boolean) = context.store.edit { it[Keys.reminders] = on }
|
suspend fun setReminders(on: Boolean) = context.store.edit { it[Keys.reminders] = on }
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package org.xtimms.psu.schedule.notify
|
package org.xtimms.psu.schedule.notify
|
||||||
|
|
||||||
import org.xtimms.psu.schedule.data.hideKey
|
import org.xtimms.psu.schedule.data.hiddenIn
|
||||||
import org.xtimms.psu.schedule.data.withPlace
|
import org.xtimms.psu.schedule.data.withPlace
|
||||||
import org.xtimms.psu.schedule.data.forSubgroup
|
import org.xtimms.psu.schedule.data.forSubgroup
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
@@ -79,7 +79,7 @@ object Reminders {
|
|||||||
val date = parseDate(day.date)
|
val date = parseDate(day.date)
|
||||||
val shown = day.lessons
|
val shown = day.lessons
|
||||||
.filter { settings.subgroup == null || it.subgroup == null || it.subgroup == settings.subgroup }
|
.filter { settings.subgroup == null || it.subgroup == null || it.subgroup == settings.subgroup }
|
||||||
.filter { it.hideKey !in settings.hidden }
|
.filter { !it.hiddenIn(settings.hidden) }
|
||||||
// A day in another town: the first lesson is announced earlier, for the road.
|
// A day in another town: the first lesson is announced earlier, for the road.
|
||||||
val away = settings.travelAlerts && day.places.forSubgroup(settings.subgroup).isNotEmpty()
|
val away = settings.travelAlerts && day.places.forSubgroup(settings.subgroup).isNotEmpty()
|
||||||
val first = shown.minOfOrNull { it.start }
|
val first = shown.minOfOrNull { it.start }
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
package org.xtimms.psu.schedule.ui
|
package org.xtimms.psu.schedule.ui
|
||||||
|
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
@@ -83,6 +85,7 @@ fun LessonAttendance(vm: MainViewModel, lesson: Lesson, date: LocalDate) {
|
|||||||
@Composable
|
@Composable
|
||||||
fun AttendanceCard(vm: MainViewModel, slots: List<Slot>) {
|
fun AttendanceCard(vm: MainViewModel, slots: List<Slot>) {
|
||||||
val marks by vm.marks.collectAsStateWithLifecycle()
|
val marks by vm.marks.collectAsStateWithLifecycle()
|
||||||
|
val haptic = LocalHapticFeedback.current
|
||||||
if (vm.currentGroupKey() == null) return
|
if (vm.currentGroupKey() == null) return
|
||||||
val now = nowTime()
|
val now = nowTime()
|
||||||
val date = today()
|
val date = today()
|
||||||
@@ -103,11 +106,17 @@ fun AttendanceCard(vm: MainViewModel, slots: List<Slot>) {
|
|||||||
Text(lesson.subject, style = MaterialTheme.typography.bodyLargeEmphasized, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
Text(lesson.subject, style = MaterialTheme.typography.bodyLargeEmphasized, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||||
TimeText("${slot.start} → ${slot.end}", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
TimeText("${slot.start} → ${slot.end}", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
}
|
}
|
||||||
FilledTonalIconButton(onClick = { vm.mark(lesson, date, true) }) {
|
FilledTonalIconButton(onClick = {
|
||||||
|
haptic.performHapticFeedback(HapticFeedbackType.Confirm)
|
||||||
|
vm.mark(lesson, date, true)
|
||||||
|
}) {
|
||||||
Icon(Icons.Rounded.Check, contentDescription = "Был на паре «${lesson.subject}»")
|
Icon(Icons.Rounded.Check, contentDescription = "Был на паре «${lesson.subject}»")
|
||||||
}
|
}
|
||||||
FilledTonalIconButton(
|
FilledTonalIconButton(
|
||||||
onClick = { vm.mark(lesson, date, false) },
|
onClick = {
|
||||||
|
haptic.performHapticFeedback(HapticFeedbackType.Confirm)
|
||||||
|
vm.mark(lesson, date, false)
|
||||||
|
},
|
||||||
colors = IconButtonDefaults.filledTonalIconButtonColors(
|
colors = IconButtonDefaults.filledTonalIconButtonColors(
|
||||||
containerColor = MaterialTheme.colorScheme.errorContainer,
|
containerColor = MaterialTheme.colorScheme.errorContainer,
|
||||||
contentColor = MaterialTheme.colorScheme.onErrorContainer,
|
contentColor = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
|||||||
@@ -123,13 +123,14 @@ private fun weekRow(month: YearMonth, d: LocalDate) = mondays(month).indexOf(d.m
|
|||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun CalendarScreen(vm: MainViewModel, nav: AppNav) {
|
fun CalendarScreen(vm: MainViewModel, nav: AppNav) {
|
||||||
|
val undo = LocalUndo.current
|
||||||
val timetable by vm.timetable.collectAsStateWithLifecycle()
|
val timetable by vm.timetable.collectAsStateWithLifecycle()
|
||||||
val settings by vm.settings.collectAsStateWithLifecycle()
|
val settings by vm.settings.collectAsStateWithLifecycle()
|
||||||
val notes by vm.notes.collectAsStateWithLifecycle()
|
val notes by vm.notes.collectAsStateWithLifecycle()
|
||||||
var selected by remember { mutableStateOf(today()) }
|
var selected by remember { mutableStateOf(today()) }
|
||||||
var details by remember { mutableStateOf<Pair<Lesson, LocalDate>?>(null) }
|
val openLesson = rememberLessonOpener(vm)
|
||||||
var editing by remember { mutableStateOf<Note?>(null) }
|
var editing by remember { mutableStateOf<Note?>(null) }
|
||||||
var adding by remember { mutableStateOf(false) }
|
val morph = LocalMorph.current
|
||||||
var target by remember { mutableStateOf<NoteTarget?>(null) }
|
var target by remember { mutableStateOf<NoteTarget?>(null) }
|
||||||
val base = remember { YearMonth.from(today()).minusMonths(MONTHS_BEFORE.toLong()) }
|
val base = remember { YearMonth.from(today()).minusMonths(MONTHS_BEFORE.toLong()) }
|
||||||
val pager = rememberPagerState(initialPage = MONTHS_BEFORE) { MONTHS }
|
val pager = rememberPagerState(initialPage = MONTHS_BEFORE) { MONTHS }
|
||||||
@@ -254,7 +255,7 @@ fun CalendarScreen(vm: MainViewModel, nav: AppNav) {
|
|||||||
val agenda: @Composable (Modifier) -> Unit = { modifier ->
|
val agenda: @Composable (Modifier) -> Unit = { modifier ->
|
||||||
DayAgenda(
|
DayAgenda(
|
||||||
vm, t, subgroup, selected, notes,
|
vm, t, subgroup, selected, notes,
|
||||||
onLesson = { details = it to selected },
|
onLesson = { openLesson(it, selected) },
|
||||||
onNote = { editing = it },
|
onNote = { editing = it },
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
@@ -304,15 +305,23 @@ fun CalendarScreen(vm: MainViewModel, nav: AppNav) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// "+" grows into the sheet of a new plan for the chosen day (Morph.kt).
|
||||||
FloatingActionButton(
|
FloatingActionButton(
|
||||||
onClick = { adding = true },
|
onClick = {
|
||||||
modifier = Modifier.align(Alignment.BottomEnd).padding(end = 16.dp, bottom = 16.dp + navBarPadding()),
|
morph?.open(MorphKind.Sheet) { close ->
|
||||||
|
AddPlanSheet(vm, t, subgroup, day = selected) {
|
||||||
|
close()
|
||||||
|
target = it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomEnd)
|
||||||
|
.padding(end = 16.dp, bottom = 16.dp + navBarPadding())
|
||||||
|
.morphOrigin("fab|calendar", MaterialTheme.colorScheme.primaryContainer, 16.dp),
|
||||||
) { Icon(Icons.Rounded.Add, contentDescription = "Новый план") }
|
) { Icon(Icons.Rounded.Add, contentDescription = "Новый план") }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (adding) ModalBottomSheet(onDismissRequest = { adding = false }) {
|
|
||||||
AddPlanSheet(vm, t, subgroup, day = selected) { adding = false; target = it }
|
|
||||||
}
|
|
||||||
target?.let { tg ->
|
target?.let { tg ->
|
||||||
vm.currentGroupKey()?.let { group ->
|
vm.currentGroupKey()?.let { group ->
|
||||||
NoteEditor(
|
NoteEditor(
|
||||||
@@ -325,18 +334,13 @@ fun CalendarScreen(vm: MainViewModel, nav: AppNav) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
details?.let { (lesson, date) ->
|
|
||||||
ModalBottomSheet(onDismissRequest = { details = null }) {
|
|
||||||
LessonDetails(lesson, t?.bells.orEmpty(), date, vm, onClose = { details = null })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
editing?.let { note ->
|
editing?.let { note ->
|
||||||
NoteEditor(
|
NoteEditor(
|
||||||
target = null,
|
target = null,
|
||||||
editing = note,
|
editing = note,
|
||||||
groupKey = note.group,
|
groupKey = note.group,
|
||||||
onSave = { vm.saveNote(it); editing = null },
|
onSave = { vm.saveNote(it); editing = null },
|
||||||
onDelete = { vm.deleteNote(it.id); editing = null },
|
onDelete = { vm.deleteNote(it.id, undo); editing = null },
|
||||||
onDismiss = { editing = null },
|
onDismiss = { editing = null },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -504,11 +508,22 @@ private fun DayAgendaContent(
|
|||||||
item(key = "plans") { AnimatedItem { Section(number, "Ваши планы", trailing = date.format(DATE)) } }
|
item(key = "plans") { AnimatedItem { Section(number, "Ваши планы", trailing = date.format(DATE)) } }
|
||||||
plans.forEachIndexed { i, note ->
|
plans.forEachIndexed { i, note ->
|
||||||
item(key = note.id) { AnimatedItem {
|
item(key = note.id) { AnimatedItem {
|
||||||
|
// Struck through in place first, then the list moves it down.
|
||||||
|
var shownDone by remember(note.id, note.done) { mutableStateOf(note.done) }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
TimelineItem(
|
TimelineItem(
|
||||||
marker = { PlanMarker(note, onToggle = { vm.toggleNote(note.id) }) },
|
marker = {
|
||||||
|
PlanMarker(note.copy(done = shownDone), onToggle = {
|
||||||
|
shownDone = !shownDone
|
||||||
|
if (shownDone) scope.launch {
|
||||||
|
kotlinx.coroutines.delay(DONE_PAUSE)
|
||||||
|
vm.toggleNote(note.id)
|
||||||
|
} else vm.toggleNote(note.id)
|
||||||
|
})
|
||||||
|
},
|
||||||
over = note.subject.ifBlank { null },
|
over = note.subject.ifBlank { null },
|
||||||
title = note.text,
|
title = note.text,
|
||||||
done = note.task && note.done,
|
done = note.task && shownDone,
|
||||||
last = i == plans.lastIndex,
|
last = i == plans.lastIndex,
|
||||||
onClick = { onNote(note) },
|
onClick = { onNote(note) },
|
||||||
)
|
)
|
||||||
@@ -540,6 +555,7 @@ private fun DayAgendaContent(
|
|||||||
).joinToString(" · ").ifEmpty { null },
|
).joinToString(" · ").ifEmpty { null },
|
||||||
last = i == lessons.lastIndex,
|
last = i == lessons.lastIndex,
|
||||||
onClick = { onLesson(l) },
|
onClick = { onLesson(l) },
|
||||||
|
origin = Modifier.morphOrigin("cal|$date|${l.key}|${l.start}", MaterialTheme.colorScheme.surfaceContainerLow, 16.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -593,6 +609,8 @@ private fun TimelineItem(
|
|||||||
overColor: Color? = null,
|
overColor: Color? = null,
|
||||||
last: Boolean,
|
last: Boolean,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
|
/** For a lesson: where its details page grows from. */
|
||||||
|
origin: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val line = MaterialTheme.colorScheme.outlineVariant
|
val line = MaterialTheme.colorScheme.outlineVariant
|
||||||
// Level with the first line of the title: 14dp padding, a 20dp line above it, a 24dp title line.
|
// Level with the first line of the title: 14dp padding, a 20dp line above it, a 24dp title line.
|
||||||
@@ -601,6 +619,7 @@ private fun TimelineItem(
|
|||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(IntrinsicSize.Min)
|
.height(IntrinsicSize.Min)
|
||||||
|
.then(origin)
|
||||||
.clip(MaterialTheme.shapes.large)
|
.clip(MaterialTheme.shapes.large)
|
||||||
.clickable(onClickLabel = "Подробнее", role = Role.Button, onClick = onClick),
|
.clickable(onClickLabel = "Подробнее", role = Role.Button, onClick = onClick),
|
||||||
) {
|
) {
|
||||||
@@ -626,14 +645,7 @@ private fun TimelineItem(
|
|||||||
over?.let {
|
over?.let {
|
||||||
TimeText(it, style = MaterialTheme.typography.bodyMedium, color = overColor ?: MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
|
TimeText(it, style = MaterialTheme.typography.bodyMedium, color = overColor ?: MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
|
||||||
}
|
}
|
||||||
Text(
|
StrikeText(title, done = done, style = MaterialTheme.typography.titleMedium, maxLines = 2)
|
||||||
title,
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
maxLines = 2,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
textDecoration = if (done) TextDecoration.LineThrough else null,
|
|
||||||
color = if (done) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface,
|
|
||||||
)
|
|
||||||
under?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1) }
|
under?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
package org.xtimms.psu.schedule.ui
|
package org.xtimms.psu.schedule.ui
|
||||||
|
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.staticCompositionLocalOf
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
import androidx.compose.ui.platform.LocalConfiguration
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
@@ -21,6 +23,26 @@ import androidx.compose.foundation.clickable
|
|||||||
import androidx.compose.foundation.layout.ColumnScope
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
|
import androidx.compose.animation.core.Spring
|
||||||
|
import androidx.compose.animation.core.snap
|
||||||
|
import androidx.compose.animation.core.spring
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.draw.drawBehind
|
||||||
|
import androidx.compose.ui.geometry.CornerRadius
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
|
import androidx.compose.ui.layout.positionInRoot
|
||||||
|
import java.time.temporal.ChronoUnit
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import androidx.compose.animation.animateColorAsState
|
import androidx.compose.animation.animateColorAsState
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.ui.graphics.lerp
|
import androidx.compose.ui.graphics.lerp
|
||||||
@@ -164,6 +186,7 @@ fun <T> ConnectedChoice(
|
|||||||
/** The check before the chosen label; times like "20:00" need the room, the fill marks it. */
|
/** The check before the chosen label; times like "20:00" need the room, the fill marks it. */
|
||||||
mark: Boolean = true,
|
mark: Boolean = true,
|
||||||
) {
|
) {
|
||||||
|
val haptic = LocalHapticFeedback.current
|
||||||
Row(
|
Row(
|
||||||
modifier.selectableGroup(),
|
modifier.selectableGroup(),
|
||||||
horizontalArrangement = Arrangement.spacedBy(ButtonGroupDefaults.ConnectedSpaceBetween),
|
horizontalArrangement = Arrangement.spacedBy(ButtonGroupDefaults.ConnectedSpaceBetween),
|
||||||
@@ -172,7 +195,10 @@ fun <T> ConnectedChoice(
|
|||||||
val checked = value == selected
|
val checked = value == selected
|
||||||
ToggleButton(
|
ToggleButton(
|
||||||
checked = checked,
|
checked = checked,
|
||||||
onCheckedChange = { onSelect(value) },
|
onCheckedChange = {
|
||||||
|
if (!checked) haptic.performHapticFeedback(HapticFeedbackType.SegmentTick)
|
||||||
|
onSelect(value)
|
||||||
|
},
|
||||||
modifier = Modifier.weight(1f).semantics { role = Role.RadioButton },
|
modifier = Modifier.weight(1f).semantics { role = Role.RadioButton },
|
||||||
// Unselected segments need their own tonal fill to read as one group.
|
// Unselected segments need their own tonal fill to read as one group.
|
||||||
colors = ToggleButtonDefaults.colors(
|
colors = ToggleButtonDefaults.colors(
|
||||||
@@ -357,6 +383,10 @@ fun weekTintOf(week: Week?): Color {
|
|||||||
* Seven days of a week in School Planner's calm style: the weekday, the date in a circle,
|
* Seven days of a week in School Planner's calm style: the weekday, the date in a circle,
|
||||||
* a filled circle for the selected day and primary text for today. Swiping between weeks
|
* a filled circle for the selected day and primary text for today. Swiping between weeks
|
||||||
* is the caller's pager; TalkBack gets the same moves as custom actions.
|
* is the caller's pager; TalkBack gets the same moves as custom actions.
|
||||||
|
*
|
||||||
|
* The filled circle is one shape under the whole row: picking another day, it flows there on
|
||||||
|
* springs, its leading edge ahead of the trailing one. With [marks], a dot in a day's circle
|
||||||
|
* says it has lessons; the dots of a week pop in one day after another when it is picked.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun DayStrip(
|
fun DayStrip(
|
||||||
@@ -366,8 +396,38 @@ fun DayStrip(
|
|||||||
enabled: (LocalDate) -> Boolean = { true },
|
enabled: (LocalDate) -> Boolean = { true },
|
||||||
onSelect: (LocalDate) -> Unit,
|
onSelect: (LocalDate) -> Unit,
|
||||||
onWeek: ((Int) -> Unit)? = null,
|
onWeek: ((Int) -> Unit)? = null,
|
||||||
|
marks: Boolean = false,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
val haptic = LocalHapticFeedback.current
|
||||||
|
val scheme = MaterialTheme.colorScheme
|
||||||
|
val reduced = rememberReducedMotion()
|
||||||
|
val index = selected?.let { ChronoUnit.DAYS.between(monday, it).toInt() }?.takeIf { it in 0..6 }
|
||||||
|
// The indicator's edges, in days from Monday.
|
||||||
|
val left = remember { Animatable(index?.toFloat() ?: 0f) }
|
||||||
|
val right = remember { Animatable(index?.toFloat() ?: 0f) }
|
||||||
|
// False until the indicator stands in this week: a week just picked gets it in place, not sliding in.
|
||||||
|
var placed by remember { mutableStateOf(index != null) }
|
||||||
|
LaunchedEffect(index) {
|
||||||
|
if (index == null) {
|
||||||
|
placed = false
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
val target = index.toFloat()
|
||||||
|
if (!placed || reduced) {
|
||||||
|
left.snapTo(target)
|
||||||
|
right.snapTo(target)
|
||||||
|
placed = true
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
val forward = target > left.value
|
||||||
|
val lead = spring<Float>(dampingRatio = 0.8f, stiffness = Spring.StiffnessMedium)
|
||||||
|
val trail = spring<Float>(dampingRatio = 0.8f, stiffness = Spring.StiffnessMediumLow)
|
||||||
|
launch { (if (forward) right else left).animateTo(target, lead) }
|
||||||
|
(if (forward) left else right).animateTo(target, trail)
|
||||||
|
}
|
||||||
|
var rowTop by remember { mutableFloatStateOf(0f) }
|
||||||
|
var circleTop by remember { mutableFloatStateOf(-1f) }
|
||||||
Row(
|
Row(
|
||||||
modifier
|
modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -380,14 +440,48 @@ fun DayStrip(
|
|||||||
CustomAccessibilityAction("Следующая неделя") { onWeek(1); true },
|
CustomAccessibilityAction("Следующая неделя") { onWeek(1); true },
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
),
|
)
|
||||||
|
.onGloballyPositioned { rowTop = it.positionInRoot().y }
|
||||||
|
.drawBehind {
|
||||||
|
if (index == null || circleTop < 0f) return@drawBehind
|
||||||
|
val cell = size.width / 7
|
||||||
|
val d = 40.dp.toPx()
|
||||||
|
val from = if (placed) left.value else index.toFloat()
|
||||||
|
val to = if (placed) right.value else index.toFloat()
|
||||||
|
drawRoundRect(
|
||||||
|
scheme.primary,
|
||||||
|
topLeft = Offset(from * cell + (cell - d) / 2, circleTop - rowTop),
|
||||||
|
size = Size((to - from) * cell + d, d),
|
||||||
|
cornerRadius = CornerRadius(d / 2),
|
||||||
|
)
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
repeat(7) { i ->
|
repeat(7) { i ->
|
||||||
val date = monday.plusDays(i.toLong())
|
val date = monday.plusDays(i.toLong())
|
||||||
val isSelected = date == selected
|
val isSelected = date == selected
|
||||||
val isToday = date == today()
|
val isToday = date == today()
|
||||||
val busy = hasLessons(date)
|
val busy = hasLessons(date)
|
||||||
val scheme = MaterialTheme.colorScheme
|
val ink by animateColorAsState(
|
||||||
|
when {
|
||||||
|
isSelected -> scheme.onPrimary
|
||||||
|
isToday -> scheme.primary
|
||||||
|
busy -> scheme.onSurface
|
||||||
|
else -> scheme.onSurfaceVariant
|
||||||
|
},
|
||||||
|
if (reduced) snap() else tween(200),
|
||||||
|
label = "day-ink",
|
||||||
|
)
|
||||||
|
// A week's dots wait until it is the one picked, then pop in from Monday on.
|
||||||
|
val dot = remember(monday) { Animatable(0f) }
|
||||||
|
val dotShown = marks && busy && (selected == null || index != null)
|
||||||
|
LaunchedEffect(monday, dotShown) {
|
||||||
|
if (!dotShown) dot.snapTo(0f)
|
||||||
|
else if (reduced || dot.value > 0f) dot.snapTo(1f)
|
||||||
|
else {
|
||||||
|
delay(60L + 35L * i)
|
||||||
|
dot.animateTo(1f, spring(dampingRatio = 0.5f, stiffness = Spring.StiffnessMedium))
|
||||||
|
}
|
||||||
|
}
|
||||||
Column(
|
Column(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -399,7 +493,10 @@ fun DayStrip(
|
|||||||
role = Role.Tab,
|
role = Role.Tab,
|
||||||
interactionSource = null,
|
interactionSource = null,
|
||||||
indication = null,
|
indication = null,
|
||||||
onClick = { onSelect(date) },
|
onClick = {
|
||||||
|
if (!isSelected) haptic.performHapticFeedback(HapticFeedbackType.SegmentTick)
|
||||||
|
onSelect(date)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
.padding(vertical = 6.dp)
|
.padding(vertical = 6.dp)
|
||||||
.semantics(mergeDescendants = true) {
|
.semantics(mergeDescendants = true) {
|
||||||
@@ -417,17 +514,16 @@ fun DayStrip(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(top = 4.dp)
|
.padding(top = 4.dp)
|
||||||
.size(40.dp)
|
.size(40.dp)
|
||||||
.background(if (isSelected) scheme.primary else Color.Transparent, CircleShape),
|
.then(if (i == 0) Modifier.onGloballyPositioned { circleTop = it.positionInRoot().y } else Modifier),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text("${date.dayOfMonth}", style = MaterialTheme.typography.titleMediumEmphasized, color = ink)
|
||||||
"${date.dayOfMonth}",
|
if (marks) Box(
|
||||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
Modifier
|
||||||
color = when {
|
.align(Alignment.BottomCenter)
|
||||||
isSelected -> scheme.onPrimary
|
.padding(bottom = 4.dp)
|
||||||
isToday -> scheme.primary
|
.size(4.dp)
|
||||||
busy -> scheme.onSurface
|
.graphicsLayer { scaleX = dot.value; scaleY = dot.value }
|
||||||
else -> scheme.onSurfaceVariant
|
.background(ink, CircleShape),
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
119
android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/Done.kt
Normal file
119
android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/Done.kt
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
package org.xtimms.psu.schedule.ui
|
||||||
|
|
||||||
|
import androidx.compose.animation.animateColorAsState
|
||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
|
import androidx.compose.animation.core.Spring
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.core.snap
|
||||||
|
import androidx.compose.animation.core.spring
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.drawWithContent
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Path
|
||||||
|
import androidx.compose.ui.graphics.PathMeasure
|
||||||
|
import androidx.compose.ui.graphics.StrokeCap
|
||||||
|
import androidx.compose.ui.graphics.StrokeJoin
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.text.TextLayoutResult
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
// Finishing a task: its text is struck through from left to right, line after line, and once the
|
||||||
|
// last open task of the plans is done a small check pops up by "Всё сделано".
|
||||||
|
|
||||||
|
/** A task's text, struck through as it is done; the line draws itself instead of appearing. */
|
||||||
|
@Composable
|
||||||
|
fun StrikeText(text: String, done: Boolean, style: TextStyle, maxLines: Int, modifier: Modifier = Modifier) {
|
||||||
|
val reduced = rememberReducedMotion()
|
||||||
|
val strike by animateFloatAsState(if (done) 1f else 0f, if (reduced) snap() else tween(350, easing = EmphasizedDecelerate), label = "strike")
|
||||||
|
val color by animateColorAsState(
|
||||||
|
if (done) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface,
|
||||||
|
tween(350),
|
||||||
|
label = "done-color",
|
||||||
|
)
|
||||||
|
var layout by remember { mutableStateOf<TextLayoutResult?>(null) }
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
style = style,
|
||||||
|
color = color,
|
||||||
|
maxLines = maxLines,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
onTextLayout = { layout = it },
|
||||||
|
modifier = modifier.drawWithContent {
|
||||||
|
drawContent()
|
||||||
|
val l = layout ?: return@drawWithContent
|
||||||
|
if (strike <= 0f) return@drawWithContent
|
||||||
|
// One run across all the lines: the first line is struck through, then the next.
|
||||||
|
val widths = (0 until l.lineCount).map { l.getLineRight(it) - l.getLineLeft(it) }
|
||||||
|
var left = widths.sum() * strike
|
||||||
|
for (i in 0 until l.lineCount) {
|
||||||
|
if (left <= 0f) break
|
||||||
|
val w = minOf(widths[i], left)
|
||||||
|
val y = (l.getLineTop(i) + l.getLineBottom(i)) / 2
|
||||||
|
drawLine(color, Offset(l.getLineLeft(i), y), Offset(l.getLineLeft(i) + w, y), strokeWidth = 1.5.dp.toPx())
|
||||||
|
left -= w
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How long a ticked task stays in place, struck through, before it moves down to the done ones. */
|
||||||
|
const val DONE_PAUSE = 420L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A check in a circle that pops up (a springy scale, M3 Expressive) and draws its tick when
|
||||||
|
* [visible] turns true; already true when shown, it just sits there.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun DoneMark(visible: Boolean, modifier: Modifier = Modifier) {
|
||||||
|
val reduced = rememberReducedMotion()
|
||||||
|
val first = remember { visible }
|
||||||
|
val scale = remember { Animatable(if (first) 1f else 0f) }
|
||||||
|
val tick = remember { Animatable(if (first) 1f else 0f) }
|
||||||
|
LaunchedEffect(visible) {
|
||||||
|
if (!visible) {
|
||||||
|
scale.snapTo(0f)
|
||||||
|
tick.snapTo(0f)
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
if (reduced) {
|
||||||
|
scale.snapTo(1f)
|
||||||
|
tick.snapTo(1f)
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
launch { scale.animateTo(1f, spring(dampingRatio = 0.45f, stiffness = Spring.StiffnessMediumLow)) }
|
||||||
|
delay(120)
|
||||||
|
tick.animateTo(1f, tween(300, easing = EmphasizedDecelerate))
|
||||||
|
}
|
||||||
|
if (!visible && scale.value == 0f) return
|
||||||
|
val fill = MaterialTheme.colorScheme.primary
|
||||||
|
val ink = MaterialTheme.colorScheme.onPrimary
|
||||||
|
Canvas(modifier.padding(end = 6.dp).size(16.dp).graphicsLayer { scaleX = scale.value; scaleY = scale.value }) {
|
||||||
|
drawCircle(fill)
|
||||||
|
val path = Path().apply {
|
||||||
|
moveTo(size.width * 0.28f, size.height * 0.52f)
|
||||||
|
lineTo(size.width * 0.44f, size.height * 0.68f)
|
||||||
|
lineTo(size.width * 0.74f, size.height * 0.36f)
|
||||||
|
}
|
||||||
|
val measure = PathMeasure().apply { setPath(path, false) }
|
||||||
|
val drawn = Path()
|
||||||
|
measure.getSegment(0f, measure.length * tick.value, drawn, true)
|
||||||
|
drawPath(drawn, ink, style = Stroke(width = 2.dp.toPx(), cap = StrokeCap.Round, join = StrokeJoin.Round))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,6 +56,7 @@ import org.xtimms.psu.schedule.data.GroupResult
|
|||||||
import org.xtimms.psu.schedule.data.attempt
|
import org.xtimms.psu.schedule.data.attempt
|
||||||
import org.xtimms.psu.schedule.data.GroupsResponse
|
import org.xtimms.psu.schedule.data.GroupsResponse
|
||||||
import org.xtimms.psu.schedule.data.SavedGroup
|
import org.xtimms.psu.schedule.data.SavedGroup
|
||||||
|
import org.xtimms.psu.schedule.data.choiceKey
|
||||||
|
|
||||||
private val PLAIN_PERIOD = Regex("^(осенний|весенний) семестр$|^расписание из word$", RegexOption.IGNORE_CASE)
|
private val PLAIN_PERIOD = Regex("^(осенний|весенний) семестр$|^расписание из word$", RegexOption.IGNORE_CASE)
|
||||||
|
|
||||||
@@ -97,15 +98,25 @@ fun GroupsScreen(
|
|||||||
}
|
}
|
||||||
val searching = query.trim().length >= 2
|
val searching = query.trim().length >= 2
|
||||||
|
|
||||||
// Before going back to the overview, a group with subgroups asks which one is yours,
|
// Before going back to the overview, a group with subgroups asks which one is yours, and a
|
||||||
// unless you already chose it for this group.
|
// group whose parts study German or French which you study, unless you chose it already.
|
||||||
var askSubgroup by remember { mutableStateOf<List<String>?>(null) }
|
var askSubgroup by remember { mutableStateOf<List<String>?>(null) }
|
||||||
|
var askChoices by remember { mutableStateOf<List<List<String>>>(emptyList()) }
|
||||||
|
var askForeign by remember { mutableStateOf(false) }
|
||||||
|
// After the subgroup, the choices and then whether you are a foreign student; then the overview.
|
||||||
|
fun next() {
|
||||||
|
if (askSubgroup == null && askChoices.isEmpty() && !askForeign) onOpened()
|
||||||
|
}
|
||||||
fun opened() {
|
fun opened() {
|
||||||
val t = vm.timetable.value
|
val t = vm.timetable.value
|
||||||
val subgroups = t?.subgroups.orEmpty()
|
val subgroups = t?.subgroups.orEmpty()
|
||||||
val s = vm.settings.value
|
val s = vm.settings.value
|
||||||
val chosen = s?.current?.group == t?.group && s?.subgroup != null
|
val same = s?.current?.group == t?.group
|
||||||
if (subgroups.isNotEmpty() && !chosen && !onboarding) askSubgroup = subgroups else onOpened()
|
if (onboarding) return onOpened()
|
||||||
|
askChoices = t?.choices.orEmpty().filterNot { same && s?.choices?.containsKey(choiceKey(it)) == true }
|
||||||
|
askForeign = t?.audience == true && !(same && s?.foreign != null)
|
||||||
|
if (subgroups.isNotEmpty() && !(same && s?.subgroup != null)) askSubgroup = subgroups
|
||||||
|
next()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun open(result: GroupResult) {
|
fun open(result: GroupResult) {
|
||||||
@@ -169,6 +180,12 @@ fun GroupsScreen(
|
|||||||
subgroups = timetable?.subgroups.orEmpty(),
|
subgroups = timetable?.subgroups.orEmpty(),
|
||||||
subgroup = settings?.subgroup,
|
subgroup = settings?.subgroup,
|
||||||
onSubgroup = vm::setSubgroup,
|
onSubgroup = vm::setSubgroup,
|
||||||
|
choices = timetable?.choices.orEmpty(),
|
||||||
|
taken = settings?.choices.orEmpty(),
|
||||||
|
onChoice = vm::setChoice,
|
||||||
|
audience = timetable?.audience == true,
|
||||||
|
foreign = settings?.foreign,
|
||||||
|
onForeign = vm::setForeign,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val recent = settings?.recent.orEmpty().filterNot { it.docId == current?.docId && it.group == current?.group }
|
val recent = settings?.recent.orEmpty().filterNot { it.docId == current?.docId && it.group == current?.group }
|
||||||
@@ -233,9 +250,7 @@ fun GroupsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (data == null && error == null) item {
|
if (data == null && error == null) item { RowsSkeleton() }
|
||||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { LoadingIndicator() }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -244,7 +259,32 @@ fun GroupsScreen(
|
|||||||
SubgroupDialog(
|
SubgroupDialog(
|
||||||
group = vm.timetable.value?.group.orEmpty(),
|
group = vm.timetable.value?.group.orEmpty(),
|
||||||
subgroups = subgroups,
|
subgroups = subgroups,
|
||||||
onPick = { vm.setSubgroup(it); askSubgroup = null; onOpened() },
|
onPick = {
|
||||||
|
vm.setSubgroup(it)
|
||||||
|
askSubgroup = null
|
||||||
|
next()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (askSubgroup == null) askChoices.firstOrNull()?.let { options ->
|
||||||
|
ChoiceDialog(
|
||||||
|
group = vm.timetable.value?.group.orEmpty(),
|
||||||
|
options = options,
|
||||||
|
onPick = {
|
||||||
|
vm.setChoice(options, it)
|
||||||
|
askChoices = askChoices.drop(1)
|
||||||
|
next()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (askSubgroup == null && askChoices.isEmpty() && askForeign) {
|
||||||
|
ForeignDialog(
|
||||||
|
group = vm.timetable.value?.group.orEmpty(),
|
||||||
|
onPick = {
|
||||||
|
vm.setForeign(it)
|
||||||
|
askForeign = false
|
||||||
|
next()
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -270,7 +310,18 @@ private fun GroupRows(results: List<GroupResult>, opening: String?, onOpen: (Gro
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun CurrentGroup(current: SavedGroup, subgroups: List<String>, subgroup: String?, onSubgroup: (String?) -> Unit) {
|
private fun CurrentGroup(
|
||||||
|
current: SavedGroup,
|
||||||
|
subgroups: List<String>,
|
||||||
|
subgroup: String?,
|
||||||
|
onSubgroup: (String?) -> Unit,
|
||||||
|
choices: List<List<String>>,
|
||||||
|
taken: Map<String, String>,
|
||||||
|
onChoice: (List<String>, String?) -> Unit,
|
||||||
|
audience: Boolean,
|
||||||
|
foreign: Boolean?,
|
||||||
|
onForeign: (Boolean?) -> Unit,
|
||||||
|
) {
|
||||||
ListCard(Icons.Rounded.CheckCircle, "Сейчас открыта") {
|
ListCard(Icons.Rounded.CheckCircle, "Сейчас открыта") {
|
||||||
CardRow(
|
CardRow(
|
||||||
avatar = avatarLabel(current.group),
|
avatar = avatarLabel(current.group),
|
||||||
@@ -291,9 +342,113 @@ private fun CurrentGroup(current: SavedGroup, subgroups: List<String>, subgroup:
|
|||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// Long names ("Стилистика русского языка. Культура речи") wrap as chips instead of a button row.
|
||||||
|
choices.forEach { options ->
|
||||||
|
val mine = taken[choiceKey(options)]
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
modifier = Modifier.padding(start = 20.dp, end = 20.dp, bottom = 12.dp),
|
||||||
|
) {
|
||||||
|
Text("Что вы изучаете", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
(listOf<String?>(null) + options).forEach { option ->
|
||||||
|
val on = mine == option
|
||||||
|
FilterChip(
|
||||||
|
selected = on,
|
||||||
|
onClick = { onChoice(options, option) },
|
||||||
|
label = { Text(option ?: "Всё") },
|
||||||
|
leadingIcon = if (on) ({ Icon(Icons.Rounded.Check, null, Modifier.size(FilterChipDefaults.IconSize)) }) else null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (audience) Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier.padding(start = 20.dp, end = 20.dp, bottom = 12.dp),
|
||||||
|
) {
|
||||||
|
Text("Иностранный студент", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
ConnectedChoice(
|
||||||
|
options = listOf<Pair<Boolean?, String>>(null to "Всё", true to "Да", false to "Нет"),
|
||||||
|
selected = foreign,
|
||||||
|
onSelect = onForeign,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun foreignText(group: String): String =
|
||||||
|
"В группе $group часть пар только для иностранных студентов («Русский язык как иностранный»), а часть — только для остальных. " +
|
||||||
|
"Ответьте, и чужие пары скроются из расписания, виджета и напоминаний. Изменить можно в разделе «Группы»."
|
||||||
|
|
||||||
|
/** "Вы иностранный студент?": the answer is remembered for this group. */
|
||||||
|
@Composable
|
||||||
|
private fun ForeignDialog(group: String, onPick: (Boolean?) -> Unit) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { onPick(null) },
|
||||||
|
icon = { Icon(Icons.Rounded.Groups, contentDescription = null) },
|
||||||
|
title = { Text("Вы иностранный студент?") },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
Text(foreignText(group), style = MaterialTheme.typography.bodyMedium)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
listOf<Pair<Boolean?, String>>(true to "Да, иностранный", false to "Нет", null to "Показывать всё").forEach { (value, label) ->
|
||||||
|
Surface(
|
||||||
|
onClick = { onPick(value) },
|
||||||
|
shape = MaterialTheme.shapes.large,
|
||||||
|
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(label, style = MaterialTheme.typography.titleSmallEmphasized, modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {},
|
||||||
|
dismissButton = { TextButton(onClick = { onPick(null) }) { Text("Позже") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a part of the group takes, in a sentence: "«Немецкий язык» или «Французский язык»". */
|
||||||
|
internal fun choiceText(group: String, options: List<String>): String =
|
||||||
|
"В группе $group часть пар идёт на выбор: ${options.joinToString(" или ") { "«$it»" }}. Выберите, что изучаете вы, — " +
|
||||||
|
"пары другого варианта скроются из расписания, виджета и напоминаний. Изменить можно в разделе «Группы»."
|
||||||
|
|
||||||
|
/** "Что вы изучаете": German or French, say; the choice is remembered for this group. */
|
||||||
|
@Composable
|
||||||
|
private fun ChoiceDialog(group: String, options: List<String>, onPick: (String?) -> Unit) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { onPick(null) },
|
||||||
|
icon = { Icon(Icons.Rounded.Groups, contentDescription = null) },
|
||||||
|
title = { Text("Что вы изучаете?") },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
Text(choiceText(group, options), style = MaterialTheme.typography.bodyMedium)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
(options + listOf<String?>(null)).forEach { option ->
|
||||||
|
Surface(
|
||||||
|
onClick = { onPick(option) },
|
||||||
|
shape = MaterialTheme.shapes.large,
|
||||||
|
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
option ?: "Показывать всё",
|
||||||
|
style = MaterialTheme.typography.titleSmallEmphasized,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {},
|
||||||
|
dismissButton = { TextButton(onClick = { onPick(null) }) { Text("Позже") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/** "Ваша подгруппа": all of them, or one; the choice is remembered for this group. */
|
/** "Ваша подгруппа": all of them, or one; the choice is remembered for this group. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun SubgroupDialog(group: String, subgroups: List<String>, onPick: (String?) -> Unit) {
|
private fun SubgroupDialog(group: String, subgroups: List<String>, onPick: (String?) -> Unit) {
|
||||||
|
|||||||
@@ -62,7 +62,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
|||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.LoadingIndicator
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.ModalBottomSheet
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
@@ -127,10 +126,11 @@ fun rememberClock(): LocalTime {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun HomeScreen(vm: MainViewModel, nav: AppNav) {
|
fun HomeScreen(vm: MainViewModel, nav: AppNav) {
|
||||||
|
val undo = LocalUndo.current
|
||||||
val timetable by vm.timetable.collectAsStateWithLifecycle()
|
val timetable by vm.timetable.collectAsStateWithLifecycle()
|
||||||
val settings by vm.settings.collectAsStateWithLifecycle()
|
val settings by vm.settings.collectAsStateWithLifecycle()
|
||||||
val status by vm.status.collectAsStateWithLifecycle()
|
val status by vm.status.collectAsStateWithLifecycle()
|
||||||
var details by remember { mutableStateOf<Pair<Lesson, LocalDate>?>(null) }
|
val openLesson = rememberLessonOpener(vm)
|
||||||
var editing by remember { mutableStateOf<Note?>(null) }
|
var editing by remember { mutableStateOf<Note?>(null) }
|
||||||
var session by remember { mutableStateOf(false) }
|
var session by remember { mutableStateOf(false) }
|
||||||
val changes by vm.changes.collectAsStateWithLifecycle()
|
val changes by vm.changes.collectAsStateWithLifecycle()
|
||||||
@@ -180,7 +180,7 @@ fun HomeScreen(vm: MainViewModel, nav: AppNav) {
|
|||||||
status.message?.let { Banner(it, offline = status.offline, onClose = vm::dismissMessage, onRetry = if (status.offline) vm::retry else null, action = if (status.needsGroup) "Выбрать группу" to nav.openGroups else null) }
|
status.message?.let { Banner(it, offline = status.offline, onClose = vm::dismissMessage, onRetry = if (status.offline) vm::retry else null, action = if (status.needsGroup) "Выбрать группу" to nav.openGroups else null) }
|
||||||
val t = timetable
|
val t = timetable
|
||||||
when {
|
when {
|
||||||
t == null && status.loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { LoadingIndicator() }
|
t == null && status.loading -> OverviewSkeleton()
|
||||||
t == null -> CenteredEmpty {
|
t == null -> CenteredEmpty {
|
||||||
Empty(
|
Empty(
|
||||||
"Расписание не загружено",
|
"Расписание не загружено",
|
||||||
@@ -196,8 +196,10 @@ fun HomeScreen(vm: MainViewModel, nav: AppNav) {
|
|||||||
Empty("Обзор недоступен", "Строки Word по дням недели — во вкладке «Расписание».", icon = Icons.Rounded.Description, action = "Открыть" to { nav.openDay(date) })
|
Empty("Обзор недоступен", "Строки Word по дням недели — во вкладке «Расписание».", icon = Icons.Rounded.Description, action = "Открыть" to { nav.openDay(date) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else -> BoxWithConstraints(Modifier.fillMaxSize()) {
|
// Pull down for the latest timetable, as on the schedule.
|
||||||
|
else -> ExpressiveRefresh(refreshing = status.loading, onRefresh = { vm.refresh() }) { BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||||
val twoPane = twoPane(maxWidth)
|
val twoPane = twoPane(maxWidth)
|
||||||
|
val intro = remember { vm.takeIntro() }
|
||||||
val overview: @Composable (Modifier) -> Unit = { listModifier ->
|
val overview: @Composable (Modifier) -> Unit = { listModifier ->
|
||||||
val todaySlots = vm.slotsOn(t, date, subgroup)
|
val todaySlots = vm.slotsOn(t, date, subgroup)
|
||||||
val next = (1..7).asSequence()
|
val next = (1..7).asSequence()
|
||||||
@@ -225,10 +227,10 @@ fun HomeScreen(vm: MainViewModel, nav: AppNav) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
item(key = "week") { AnimatedItem { WeekReport(vm, t, subgroup, date, clock) } }
|
item(key = "week") { AnimatedItem { Entrance(0, intro) { WeekReport(vm, t, subgroup, date, clock) } } }
|
||||||
item(key = "session") { AnimatedItem { SessionCard(vm, t, onOpen = { session = true }) } }
|
item(key = "session") { AnimatedItem { Entrance(1, intro) { SessionCard(vm, t, onOpen = { session = true }) } } }
|
||||||
item(key = "today-title") { AnimatedItem { DayTitle("Сегодня", date) } }
|
item(key = "today-title") { AnimatedItem { Entrance(2, intro) { DayTitle("Сегодня", date) } } }
|
||||||
item(key = "today") { AnimatedItem { TodayCard(todaySlots, vm.placesOn(t, date, subgroup), clock, onOpen = { details = it to date }, onDay = { nav.openDay(date) }) } }
|
item(key = "today") { AnimatedItem { Entrance(3, intro) { TodayCard(todaySlots, vm.placesOn(t, date, subgroup), clock, onOpen = { openLesson(it, date) }, onDay = { nav.openDay(date) }) } } }
|
||||||
item(key = "attendance") { AnimatedItem { AttendanceCard(vm, todaySlots) } }
|
item(key = "attendance") { AnimatedItem { AttendanceCard(vm, todaySlots) } }
|
||||||
item(key = "tasks") { AnimatedItem { TasksCard(vm, onEdit = { editing = it }, onAll = nav.openPlans) } }
|
item(key = "tasks") { AnimatedItem { TasksCard(vm, onEdit = { editing = it }, onAll = nav.openPlans) } }
|
||||||
if (next != null) {
|
if (next != null) {
|
||||||
@@ -237,7 +239,7 @@ fun HomeScreen(vm: MainViewModel, nav: AppNav) {
|
|||||||
item(key = "next") {
|
item(key = "next") {
|
||||||
SectionCard(Icons.Rounded.EventAvailable, plural(slots.size, "пара", "пары", "пар").let { "${slots.size} $it" }) {
|
SectionCard(Icons.Rounded.EventAvailable, plural(slots.size, "пара", "пары", "пар").let { "${slots.size} $it" }) {
|
||||||
DayPlaces(vm.placesOn(t, day, subgroup))
|
DayPlaces(vm.placesOn(t, day, subgroup))
|
||||||
slots.forEach { slot -> slot.lessons.forEach { CompactLesson(it, slot, onClick = { details = it to day }) } }
|
slots.forEach { slot -> slot.lessons.forEach { CompactLesson(it, slot, onClick = { openLesson(it, day) }) } }
|
||||||
DayButton { nav.openDay(day) }
|
DayButton { nav.openDay(day) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -248,10 +250,10 @@ fun HomeScreen(vm: MainViewModel, nav: AppNav) {
|
|||||||
// a foldable half opened like a book puts one on each side of the fold.
|
// a foldable half opened like a book puts one on each side of the fold.
|
||||||
if (twoPane) PaneRow(Modifier.fillMaxSize().padding(end = 16.dp), firstWidth = 440.dp, gap = 0.dp, first = { overview(it) }) {
|
if (twoPane) PaneRow(Modifier.fillMaxSize().padding(end = 16.dp), firstWidth = 440.dp, gap = 0.dp, first = { overview(it) }) {
|
||||||
DetailPane(it) {
|
DetailPane(it) {
|
||||||
WeekPane(vm, t, subgroup, onOpen = { lesson, day -> details = lesson to day })
|
WeekPane(vm, t, subgroup, onOpen = { lesson, day -> openLesson(lesson, day) })
|
||||||
}
|
}
|
||||||
} else ContentColumn { overview(Modifier.fillMaxSize()) }
|
} else ContentColumn { overview(Modifier.fillMaxSize()) }
|
||||||
}
|
} }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (changesOpen) changes?.let { record ->
|
if (changesOpen) changes?.let { record ->
|
||||||
@@ -267,13 +269,10 @@ fun HomeScreen(vm: MainViewModel, nav: AppNav) {
|
|||||||
editing = note,
|
editing = note,
|
||||||
groupKey = note.group,
|
groupKey = note.group,
|
||||||
onSave = { vm.saveNote(it); editing = null },
|
onSave = { vm.saveNote(it); editing = null },
|
||||||
onDelete = { vm.deleteNote(it.id); editing = null },
|
onDelete = { vm.deleteNote(it.id, undo); editing = null },
|
||||||
onDismiss = { editing = null },
|
onDismiss = { editing = null },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
details?.let { lesson ->
|
|
||||||
ModalBottomSheet(onDismissRequest = { details = null }) { LessonDetails(lesson.first, timetable?.bells.orEmpty(), lesson.second, vm, onClose = { details = null }) }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Shortcuts under the app bar, as in School Planner's overview. */
|
/** Shortcuts under the app bar, as in School Planner's overview. */
|
||||||
@@ -520,6 +519,7 @@ private fun FeaturedLesson(lesson: Lesson, slot: Slot, clock: LocalTime, onClick
|
|||||||
Row(
|
Row(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
.morphOrigin("featured|${slot.date}|${lesson.key}|${lesson.start}", MaterialTheme.colorScheme.card, 12.dp)
|
||||||
.clip(MaterialTheme.shapes.medium)
|
.clip(MaterialTheme.shapes.medium)
|
||||||
.clickable(onClickLabel = "Подробнее", role = Role.Button, onClick = onClick),
|
.clickable(onClickLabel = "Подробнее", role = Role.Button, onClick = onClick),
|
||||||
) {
|
) {
|
||||||
@@ -537,7 +537,7 @@ private fun FeaturedLesson(lesson: Lesson, slot: Slot, clock: LocalTime, onClick
|
|||||||
color = if (running) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.secondaryContainer,
|
color = if (running) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.secondaryContainer,
|
||||||
contentColor = if (running) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSecondaryContainer,
|
contentColor = if (running) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
) {
|
) {
|
||||||
Text(
|
RollingText(
|
||||||
if (running) "Идёт · ещё ${minutesLabel(minutes)}" else "Через ${minutesLabel(minutes)}",
|
if (running) "Идёт · ещё ${minutesLabel(minutes)}" else "Через ${minutesLabel(minutes)}",
|
||||||
style = MaterialTheme.typography.labelLarge,
|
style = MaterialTheme.typography.labelLarge,
|
||||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
|
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||||
@@ -560,7 +560,10 @@ fun CompactLesson(lesson: Lesson, slot: Slot, onClick: (() -> Unit)?) {
|
|||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.then(
|
.then(
|
||||||
if (onClick != null) Modifier.clip(MaterialTheme.shapes.medium).clickable(onClickLabel = "Подробнее", role = Role.Button, onClick = onClick)
|
if (onClick != null) Modifier
|
||||||
|
.morphOrigin("compact|${slot.date}|${lesson.key}|${lesson.start}", MaterialTheme.colorScheme.card, 12.dp)
|
||||||
|
.clip(MaterialTheme.shapes.medium)
|
||||||
|
.clickable(onClickLabel = "Подробнее", role = Role.Button, onClick = onClick)
|
||||||
else Modifier,
|
else Modifier,
|
||||||
),
|
),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
|||||||
@@ -74,7 +74,19 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun saveNote(note: Note) = viewModelScope.launch { app.notes.save(note) }
|
fun saveNote(note: Note) = viewModelScope.launch { app.notes.save(note) }
|
||||||
fun deleteNote(id: String) = viewModelScope.launch { app.notes.delete(id) }
|
/** Deletes at once and offers it back in [undo]; the photos go only when the offer is gone. */
|
||||||
|
fun deleteNote(id: String, undo: UndoHost? = null) = viewModelScope.launch {
|
||||||
|
val note = app.notes.remove(id) ?: return@launch
|
||||||
|
if (undo == null) {
|
||||||
|
app.notes.discard(note.photos)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
undo.offer(
|
||||||
|
if (note.task) "Задание удалено" else "Заметка удалена",
|
||||||
|
onUndo = { viewModelScope.launch { app.notes.save(note) } },
|
||||||
|
onGone = { app.notes.discard(note.photos) },
|
||||||
|
)
|
||||||
|
}
|
||||||
fun toggleNote(id: String) = viewModelScope.launch { app.notes.toggle(id) }
|
fun toggleNote(id: String) = viewModelScope.launch { app.notes.toggle(id) }
|
||||||
|
|
||||||
/** The key new notes are filed under, or null before a group is chosen. */
|
/** The key new notes are filed under, or null before a group is chosen. */
|
||||||
@@ -148,6 +160,16 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
SyncWorker.afterChange(getApplication())
|
SyncWorker.afterChange(getApplication())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setChoice(options: List<String>, option: String?) = viewModelScope.launch {
|
||||||
|
app.settings.setChoice(options, option)
|
||||||
|
SyncWorker.afterChange(getApplication())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setForeign(foreign: Boolean?) = viewModelScope.launch {
|
||||||
|
app.settings.setForeign(foreign)
|
||||||
|
SyncWorker.afterChange(getApplication())
|
||||||
|
}
|
||||||
|
|
||||||
fun setTheme(mode: ThemeMode) = viewModelScope.launch { app.settings.setTheme(mode) }
|
fun setTheme(mode: ThemeMode) = viewModelScope.launch { app.settings.setTheme(mode) }
|
||||||
fun setDynamicColor(on: Boolean) = viewModelScope.launch { app.settings.setDynamicColor(on) }
|
fun setDynamicColor(on: Boolean) = viewModelScope.launch { app.settings.setDynamicColor(on) }
|
||||||
fun setDisplay(display: Display) = viewModelScope.launch { app.settings.setDisplay(display) }
|
fun setDisplay(display: Display) = viewModelScope.launch { app.settings.setDisplay(display) }
|
||||||
@@ -166,6 +188,14 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
|
|
||||||
fun setChangeAlerts(on: Boolean) = viewModelScope.launch { app.settings.setChangeAlerts(on) }
|
fun setChangeAlerts(on: Boolean) = viewModelScope.launch { app.settings.setChangeAlerts(on) }
|
||||||
|
|
||||||
|
/** The overview's cards rise in once per launch, after the launch screen, not on every return. */
|
||||||
|
private var introShown = false
|
||||||
|
fun takeIntro(): Boolean = !introShown.also { introShown = true }
|
||||||
|
|
||||||
|
/** Lessons a republished document just added or changed; each card glows once. */
|
||||||
|
val fresh: StateFlow<Set<String>> = app.repository.fresh
|
||||||
|
fun freshShown(key: String) = app.repository.freshShown(key)
|
||||||
|
|
||||||
/** "Новое задание" from the launcher: the plans open with the add sheet. */
|
/** "Новое задание" from the launcher: the plans open with the add sheet. */
|
||||||
val newTask = kotlinx.coroutines.flow.MutableStateFlow(false)
|
val newTask = kotlinx.coroutines.flow.MutableStateFlow(false)
|
||||||
|
|
||||||
|
|||||||
261
android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/Morph.kt
Normal file
261
android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/Morph.kt
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
package org.xtimms.psu.schedule.ui
|
||||||
|
|
||||||
|
import androidx.activity.compose.PredictiveBackHandler
|
||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||||
|
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||||
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
|
import androidx.compose.foundation.gestures.detectVerticalDragGestures
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.Stable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.drawBehind
|
||||||
|
import androidx.compose.ui.draw.drawWithContent
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Rect
|
||||||
|
import androidx.compose.ui.geometry.lerp
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.graphics.layer.GraphicsLayer
|
||||||
|
import androidx.compose.ui.graphics.layer.drawLayer
|
||||||
|
import androidx.compose.ui.graphics.lerp
|
||||||
|
import androidx.compose.ui.graphics.rememberGraphicsLayer
|
||||||
|
import androidx.compose.ui.graphics.drawscope.scale
|
||||||
|
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.layout.Layout
|
||||||
|
import androidx.compose.ui.layout.boundsInRoot
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
|
import androidx.compose.ui.layout.positionInRoot
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.unit.Constraints
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.lerp
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
// The M3 container transform: a card grows into its details, the "+" button into the sheet of a
|
||||||
|
// new plan, and back. Bottom sheets live in windows of their own and cannot grow from a card, so
|
||||||
|
// these open in one overlay over the app (MorphOverlay) instead.
|
||||||
|
|
||||||
|
/** Where a transform starts: an element's bounds, its fill and corners and a picture of it. */
|
||||||
|
class MorphOrigin(val bounds: Rect, val color: Color, val radius: Dp, val picture: GraphicsLayer)
|
||||||
|
|
||||||
|
enum class MorphKind {
|
||||||
|
/** A page: the whole screen on a phone, a large panel on a tablet. */
|
||||||
|
Page,
|
||||||
|
|
||||||
|
/** A sheet from the bottom, with a handle to drag it away. */
|
||||||
|
Sheet,
|
||||||
|
}
|
||||||
|
|
||||||
|
class MorphEntry(val key: String?, val kind: MorphKind, val content: @Composable (close: () -> Unit) -> Unit)
|
||||||
|
|
||||||
|
@Stable
|
||||||
|
class MorphHost {
|
||||||
|
internal val origins = mutableMapOf<String, MorphOrigin>()
|
||||||
|
// The element the finger went down on, and when: the page grows from what was tapped.
|
||||||
|
internal var touched: String? = null
|
||||||
|
internal var touchedAt = 0L
|
||||||
|
var entry by mutableStateOf<MorphEntry?>(null)
|
||||||
|
internal set
|
||||||
|
internal var closing by mutableStateOf(false)
|
||||||
|
internal val progress = Animatable(0f)
|
||||||
|
|
||||||
|
/** Opens [content] growing out of the element tapped a moment ago, if any. */
|
||||||
|
fun open(kind: MorphKind, content: @Composable (close: () -> Unit) -> Unit) {
|
||||||
|
val key = touched?.takeIf { System.currentTimeMillis() - touchedAt < 1500 }
|
||||||
|
closing = false
|
||||||
|
entry = MorphEntry(key, kind, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun close() {
|
||||||
|
if (entry != null) closing = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val LocalMorph = staticCompositionLocalOf<MorphHost?> { null }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks a card or a button as a place a page or a sheet grows from. While its page is open the
|
||||||
|
* element itself is hidden: the page has taken its place.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun Modifier.morphOrigin(key: String, color: Color, radius: Dp): Modifier {
|
||||||
|
val host = LocalMorph.current ?: return this
|
||||||
|
val picture = rememberGraphicsLayer()
|
||||||
|
DisposableEffect(host, key) { onDispose { host.origins.remove(key) } }
|
||||||
|
val hidden = host.entry?.key == key
|
||||||
|
return this
|
||||||
|
.onGloballyPositioned { host.origins[key] = MorphOrigin(it.boundsInRoot(), color, radius, picture) }
|
||||||
|
.pointerInput(host, key) {
|
||||||
|
awaitEachGesture {
|
||||||
|
awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
|
||||||
|
host.touched = key
|
||||||
|
host.touchedAt = System.currentTimeMillis()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.drawWithContent {
|
||||||
|
picture.record { this@drawWithContent.drawContent() }
|
||||||
|
if (!hidden) drawLayer(picture)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The open page or sheet over the app. Opening, it grows from its origin (or from the middle):
|
||||||
|
* the origin's picture fades out over the first third, the content fades in after it. Back,
|
||||||
|
* a tap on the scrim or the predictive back gesture shrink it back the same way.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun MorphOverlay(host: MorphHost) {
|
||||||
|
val entry = host.entry ?: return
|
||||||
|
val reduced = rememberReducedMotion()
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var back by remember { mutableFloatStateOf(0f) }
|
||||||
|
var drag by remember { mutableFloatStateOf(0f) }
|
||||||
|
var rootOffset by remember { mutableStateOf(Offset.Zero) }
|
||||||
|
LaunchedEffect(entry) {
|
||||||
|
back = 0f
|
||||||
|
drag = 0f
|
||||||
|
host.progress.snapTo(0f)
|
||||||
|
host.progress.animateTo(1f, tween(if (reduced) 0 else 450, easing = Emphasized))
|
||||||
|
}
|
||||||
|
LaunchedEffect(host.closing) {
|
||||||
|
if (!host.closing) return@LaunchedEffect
|
||||||
|
host.progress.animateTo(0f, tween(if (reduced) 0 else 350, easing = Emphasized))
|
||||||
|
host.entry = null
|
||||||
|
host.closing = false
|
||||||
|
}
|
||||||
|
PredictiveBackHandler(enabled = !host.closing) { events ->
|
||||||
|
try {
|
||||||
|
events.collect { back = it.progress }
|
||||||
|
host.close()
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
scope.launch { back = 0f }
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val surface = MaterialTheme.colorScheme.surfaceContainerLow
|
||||||
|
BoxWithConstraints(Modifier.fillMaxSize().onGloballyPositioned { rootOffset = it.positionInRoot() }) {
|
||||||
|
val w = constraints.maxWidth.toFloat()
|
||||||
|
val h = constraints.maxHeight.toFloat()
|
||||||
|
val wide = maxWidth >= 600.dp
|
||||||
|
val margin = with(density) { 24.dp.toPx() }
|
||||||
|
val target = when (entry.kind) {
|
||||||
|
MorphKind.Page -> if (wide) {
|
||||||
|
val pw = minOf(w - 2 * margin, with(density) { 680.dp.toPx() })
|
||||||
|
Rect(Offset((w - pw) / 2, margin), androidx.compose.ui.geometry.Size(pw, h - 2 * margin))
|
||||||
|
} else Rect(0f, 0f, w, h)
|
||||||
|
MorphKind.Sheet -> {
|
||||||
|
val sw = minOf(w, with(density) { 640.dp.toPx() })
|
||||||
|
val sh = h * 0.82f
|
||||||
|
Rect(Offset((w - sw) / 2, h - sh), androidx.compose.ui.geometry.Size(sw, sh))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val origin = entry.key?.let { host.origins[it] }
|
||||||
|
val from = origin?.bounds?.translate(-rootOffset) ?: Rect(target.center, 0f).inflate(minOf(target.width, target.height) * 0.3f)
|
||||||
|
val p = host.progress.value
|
||||||
|
val rect = lerp(from, target, p)
|
||||||
|
val targetRadius = when {
|
||||||
|
entry.kind == MorphKind.Sheet -> 28.dp
|
||||||
|
wide -> 28.dp
|
||||||
|
else -> 0.dp
|
||||||
|
}
|
||||||
|
val radius = lerp(origin?.radius ?: 28.dp, targetRadius, p)
|
||||||
|
val shape = if (entry.kind == MorphKind.Sheet) RoundedCornerShape(radius, radius, lerp(origin?.radius ?: 28.dp, 0.dp, p), lerp(origin?.radius ?: 28.dp, 0.dp, p))
|
||||||
|
else RoundedCornerShape(radius)
|
||||||
|
// The scrim; a tap on it closes.
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.Black.copy(alpha = 0.32f * p))
|
||||||
|
.pointerInput(host) { detectTapGestures { host.close() } },
|
||||||
|
)
|
||||||
|
Layout(
|
||||||
|
content = {
|
||||||
|
// The content, laid out once at its final size, revealed by the growing bounds.
|
||||||
|
Box(Modifier.graphicsLayer { alpha = ((p - 0.3f) / 0.7f).coerceIn(0f, 1f) }) {
|
||||||
|
if (entry.kind == MorphKind.Sheet) SheetChrome(onDrag = { drag = (drag + it).coerceAtLeast(0f) }, onRelease = {
|
||||||
|
if (drag > with(density) { 120.dp.toPx() }) host.close() else drag = 0f
|
||||||
|
}) { entry.content(host::close) }
|
||||||
|
else entry.content(host::close)
|
||||||
|
}
|
||||||
|
// The origin's own picture, fading out as the page takes over.
|
||||||
|
if (origin != null) Box(
|
||||||
|
Modifier
|
||||||
|
.graphicsLayer { alpha = (1f - p / 0.3f).coerceIn(0f, 1f) }
|
||||||
|
.drawBehind {
|
||||||
|
val s = rect.width / origin.bounds.width.coerceAtLeast(1f)
|
||||||
|
scale(s, s, pivot = Offset.Zero) { drawLayer(origin.picture) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.offset { IntOffset(rect.left.roundToInt(), rect.top.roundToInt()) }
|
||||||
|
.graphicsLayer {
|
||||||
|
val s = 1f - 0.08f * back
|
||||||
|
scaleX = s
|
||||||
|
scaleY = s
|
||||||
|
translationY = drag
|
||||||
|
}
|
||||||
|
.clip(shape)
|
||||||
|
.background(lerp(origin?.color ?: surface, surface, (p / 0.5f).coerceIn(0f, 1f)))
|
||||||
|
// Taps inside the page never reach the scrim under it.
|
||||||
|
.pointerInput(Unit) { detectTapGestures { } },
|
||||||
|
) { measurables, _ ->
|
||||||
|
val content = measurables[0].measure(Constraints.fixed(target.width.roundToInt(), target.height.roundToInt()))
|
||||||
|
val picture = measurables.getOrNull(1)?.measure(Constraints.fixed(rect.width.roundToInt().coerceAtLeast(0), rect.height.roundToInt().coerceAtLeast(0)))
|
||||||
|
layout(rect.width.roundToInt().coerceAtLeast(0), rect.height.roundToInt().coerceAtLeast(0)) {
|
||||||
|
content.place((target.left - rect.left).roundToInt(), (target.top - rect.top).roundToInt())
|
||||||
|
picture?.place(0, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A sheet's handle, which drags the sheet down and away. */
|
||||||
|
@Composable
|
||||||
|
private fun SheetChrome(onDrag: (Float) -> Unit, onRelease: () -> Unit, content: @Composable () -> Unit) {
|
||||||
|
Column(Modifier.fillMaxSize()) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(36.dp)
|
||||||
|
.pointerInput(Unit) { detectVerticalDragGestures(onDragEnd = onRelease, onDragCancel = onRelease) { _, dy -> onDrag(dy) } },
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Surface(shape = RoundedCornerShape(2.dp), color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), modifier = Modifier.size(32.dp, 4.dp)) {}
|
||||||
|
}
|
||||||
|
Box(Modifier.weight(1f)) { content() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,8 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.draw.drawWithContent
|
||||||
|
|
||||||
// Material motion patterns, as in RideBus: shared axis X between a screen and the page it
|
// Material motion patterns, as in RideBus: shared axis X between a screen and the page it
|
||||||
// opens, fade through between top-level destinations.
|
// opens, fade through between top-level destinations.
|
||||||
@@ -103,3 +105,130 @@ fun LazyStaggeredGridItemScope.AnimatedItem(content: @Composable () -> Unit) {
|
|||||||
fun materialFadeThrough(): ContentTransform =
|
fun materialFadeThrough(): ContentTransform =
|
||||||
(fadeIn(tween(400, easing = EmphasizedDecelerate)) + scaleIn(tween(400, easing = EmphasizedDecelerate), initialScale = 0.92f)) togetherWith
|
(fadeIn(tween(400, easing = EmphasizedDecelerate)) + scaleIn(tween(400, easing = EmphasizedDecelerate), initialScale = 0.92f)) togetherWith
|
||||||
fadeOut(tween(200, easing = EmphasizedAccelerate))
|
fadeOut(tween(200, easing = EmphasizedAccelerate))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the student turned animations off ("Убрать анимацию" sets the animator scale to 0):
|
||||||
|
* decorative motion is skipped then.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun rememberReducedMotion(): Boolean {
|
||||||
|
val resolver = androidx.compose.ui.platform.LocalContext.current.contentResolver
|
||||||
|
return remember {
|
||||||
|
android.provider.Settings.Global.getFloat(resolver, android.provider.Settings.Global.ANIMATOR_DURATION_SCALE, 1f) == 0f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The overview's cards after the launch screen: each rises 24dp and fades in, a little after
|
||||||
|
* the one above it. Only on [play] (the first overview of a launch), never with reduced motion.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun Entrance(index: Int, play: Boolean, content: @Composable () -> Unit) {
|
||||||
|
val reduced = rememberReducedMotion()
|
||||||
|
if (!play || reduced) {
|
||||||
|
content()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val shown = remember { androidx.compose.animation.core.Animatable(0f) }
|
||||||
|
androidx.compose.runtime.LaunchedEffect(Unit) {
|
||||||
|
kotlinx.coroutines.delay(60L * index)
|
||||||
|
shown.animateTo(1f, tween(MotionDuration.Medium4, easing = EmphasizedDecelerate))
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
Modifier.graphicsLayer {
|
||||||
|
alpha = shown.value
|
||||||
|
translationY = (1f - shown.value) * 24.dp.toPx()
|
||||||
|
},
|
||||||
|
) { content() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A short changing label ("ещё 25 мин" → "ещё 24 мин") that rolls up to its new value instead
|
||||||
|
* of flicking; still with animations turned off.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun RollingText(text: String, style: androidx.compose.ui.text.TextStyle, modifier: Modifier = Modifier) {
|
||||||
|
if (rememberReducedMotion()) {
|
||||||
|
androidx.compose.material3.Text(text, style = style, modifier = modifier)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
androidx.compose.animation.AnimatedContent(
|
||||||
|
targetState = text,
|
||||||
|
transitionSpec = {
|
||||||
|
(androidx.compose.animation.slideInVertically(tween(MotionDuration.Short4, easing = EmphasizedDecelerate)) { it } + fadeIn(tween(MotionDuration.Short4))) togetherWith
|
||||||
|
(androidx.compose.animation.slideOutVertically(tween(MotionDuration.Short4, easing = EmphasizedAccelerate)) { -it } + fadeOut(tween(MotionDuration.Short4))) using
|
||||||
|
androidx.compose.animation.SizeTransform(clip = true)
|
||||||
|
},
|
||||||
|
label = "rolling",
|
||||||
|
modifier = modifier,
|
||||||
|
) { androidx.compose.material3.Text(it, style = style) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The lesson under way: a line runs around the card's edge as the pair goes, from its top-left
|
||||||
|
* corner clockwise, over a faint full outline. [progress] is 0..1.
|
||||||
|
*/
|
||||||
|
fun Modifier.progressBorder(progress: Float, color: androidx.compose.ui.graphics.Color, radius: androidx.compose.ui.unit.Dp): Modifier =
|
||||||
|
drawWithContent {
|
||||||
|
drawContent()
|
||||||
|
val stroke = 3.dp.toPx()
|
||||||
|
val inset = stroke / 2
|
||||||
|
val r = radius.toPx() - inset
|
||||||
|
// Round the card clockwise from just after its top-left corner, so the line starts there.
|
||||||
|
val l = inset
|
||||||
|
val t = inset
|
||||||
|
val rr = size.width - inset
|
||||||
|
val b = size.height - inset
|
||||||
|
val path = androidx.compose.ui.graphics.Path().apply {
|
||||||
|
moveTo(l + r, t)
|
||||||
|
lineTo(rr - r, t)
|
||||||
|
arcTo(androidx.compose.ui.geometry.Rect(rr - 2 * r, t, rr, t + 2 * r), -90f, 90f, false)
|
||||||
|
lineTo(rr, b - r)
|
||||||
|
arcTo(androidx.compose.ui.geometry.Rect(rr - 2 * r, b - 2 * r, rr, b), 0f, 90f, false)
|
||||||
|
lineTo(l + r, b)
|
||||||
|
arcTo(androidx.compose.ui.geometry.Rect(l, b - 2 * r, l + 2 * r, b), 90f, 90f, false)
|
||||||
|
lineTo(l, t + r)
|
||||||
|
arcTo(androidx.compose.ui.geometry.Rect(l, t, l + 2 * r, t + 2 * r), 180f, 90f, false)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
drawPath(path, color.copy(alpha = 0.22f), style = androidx.compose.ui.graphics.drawscope.Stroke(width = 2.dp.toPx()))
|
||||||
|
val measure = androidx.compose.ui.graphics.PathMeasure().apply { setPath(path, false) }
|
||||||
|
val done = androidx.compose.ui.graphics.Path()
|
||||||
|
measure.getSegment(0f, measure.length * progress.coerceIn(0f, 1f), done, true)
|
||||||
|
drawPath(done, color, style = androidx.compose.ui.graphics.drawscope.Stroke(width = stroke, cap = androidx.compose.ui.graphics.StrokeCap.Round))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pull to refresh with the M3 Expressive loading indicator, its shape morphing while the
|
||||||
|
* timetable loads; a tick under the finger when the pull is far enough to refresh.
|
||||||
|
*/
|
||||||
|
@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class, androidx.compose.material3.ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
fun ExpressiveRefresh(
|
||||||
|
refreshing: Boolean,
|
||||||
|
onRefresh: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
content: @Composable androidx.compose.foundation.layout.BoxScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
val state = androidx.compose.material3.pulltorefresh.rememberPullToRefreshState()
|
||||||
|
val haptic = androidx.compose.ui.platform.LocalHapticFeedback.current
|
||||||
|
val busy = androidx.compose.runtime.rememberUpdatedState(refreshing)
|
||||||
|
androidx.compose.runtime.LaunchedEffect(state) {
|
||||||
|
androidx.compose.runtime.snapshotFlow { state.distanceFraction >= 1f }
|
||||||
|
.collect { far -> if (far && !busy.value) haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.GestureThresholdActivate) }
|
||||||
|
}
|
||||||
|
androidx.compose.material3.pulltorefresh.PullToRefreshBox(
|
||||||
|
isRefreshing = refreshing,
|
||||||
|
onRefresh = onRefresh,
|
||||||
|
state = state,
|
||||||
|
modifier = modifier,
|
||||||
|
indicator = {
|
||||||
|
androidx.compose.material3.pulltorefresh.PullToRefreshDefaults.LoadingIndicator(
|
||||||
|
state = state,
|
||||||
|
isRefreshing = refreshing,
|
||||||
|
modifier = Modifier.align(androidx.compose.ui.Alignment.TopCenter),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,11 @@
|
|||||||
|
|
||||||
package org.xtimms.psu.schedule.ui
|
package org.xtimms.psu.schedule.ui
|
||||||
|
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material.icons.rounded.CalendarToday
|
import androidx.compose.material.icons.rounded.CalendarToday
|
||||||
@@ -9,7 +14,13 @@ import androidx.compose.material3.rememberDatePickerState
|
|||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.DatePickerDialog
|
import androidx.compose.material3.DatePickerDialog
|
||||||
import androidx.compose.material3.DatePicker
|
import androidx.compose.material3.DatePicker
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.rounded.Image
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import org.xtimms.psu.schedule.app
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -111,6 +122,11 @@ fun MainViewModel.nextOccurrence(t: Timetable, subject: String, after: LocalDate
|
|||||||
@Composable
|
@Composable
|
||||||
fun NoteRow(note: Note, onToggle: () -> Unit, onClick: () -> Unit, showSubject: Boolean, showDue: Boolean = true) {
|
fun NoteRow(note: Note, onToggle: () -> Unit, onClick: () -> Unit, showSubject: Boolean, showDue: Boolean = true) {
|
||||||
val overdue = note.overdue()
|
val overdue = note.overdue()
|
||||||
|
var viewing by remember { mutableStateOf(false) }
|
||||||
|
val haptic = LocalHapticFeedback.current
|
||||||
|
// A ticked task is struck through in place first, then moves down to the done ones.
|
||||||
|
var shownDone by remember(note.id, note.done) { mutableStateOf(note.done) }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -119,20 +135,20 @@ fun NoteRow(note: Note, onToggle: () -> Unit, onClick: () -> Unit, showSubject:
|
|||||||
.clickable(onClickLabel = "Изменить", role = Role.Button, onClick = onClick)
|
.clickable(onClickLabel = "Изменить", role = Role.Button, onClick = onClick)
|
||||||
.padding(start = 8.dp, end = 16.dp, top = 2.dp, bottom = 2.dp),
|
.padding(start = 8.dp, end = 16.dp, top = 2.dp, bottom = 2.dp),
|
||||||
) {
|
) {
|
||||||
if (note.task) Checkbox(checked = note.done, onCheckedChange = { onToggle() })
|
if (note.task) Checkbox(checked = shownDone, onCheckedChange = { done ->
|
||||||
|
haptic.performHapticFeedback(if (done) HapticFeedbackType.Confirm else HapticFeedbackType.ToggleOff)
|
||||||
|
shownDone = done
|
||||||
|
if (done) scope.launch {
|
||||||
|
delay(DONE_PAUSE)
|
||||||
|
onToggle()
|
||||||
|
} else onToggle()
|
||||||
|
})
|
||||||
else Box(Modifier.size(48.dp), contentAlignment = Alignment.Center) {
|
else Box(Modifier.size(48.dp), contentAlignment = Alignment.Center) {
|
||||||
Icon(Icons.Rounded.StickyNote2, contentDescription = "Заметка", tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
Icon(Icons.Rounded.StickyNote2, contentDescription = "Заметка", tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
Column(Modifier.weight(1f).padding(vertical = 8.dp)) {
|
Column(Modifier.weight(1f).padding(vertical = 8.dp)) {
|
||||||
Text(
|
StrikeText(note.text, done = note.task && shownDone, style = MaterialTheme.typography.bodyLarge, maxLines = 3)
|
||||||
note.text,
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
textDecoration = if (note.task && note.done) TextDecoration.LineThrough else null,
|
|
||||||
color = if (note.task && note.done) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface,
|
|
||||||
maxLines = 3,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
if (showSubject && !note.general) {
|
if (showSubject && !note.general) {
|
||||||
SubjectDot(note.subject, 8.dp)
|
SubjectDot(note.subject, 8.dp)
|
||||||
@@ -155,7 +171,22 @@ fun NoteRow(note: Note, onToggle: () -> Unit, onClick: () -> Unit, showSubject:
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// The first photo, with how many more; it opens the viewer, the row the editor.
|
||||||
|
if (note.photos.isNotEmpty()) Box(Modifier.padding(start = 12.dp)) {
|
||||||
|
PhotoThumb(note.photos.first(), 48.dp, onClick = { viewing = true })
|
||||||
|
if (note.photos.size > 1) Text(
|
||||||
|
"+${note.photos.size - 1}",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.inverseOnSurface,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomEnd)
|
||||||
|
.padding(3.dp)
|
||||||
|
.background(MaterialTheme.colorScheme.inverseSurface.copy(alpha = 0.85f), MaterialTheme.shapes.small)
|
||||||
|
.padding(horizontal = 5.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
if (viewing) PhotoViewer(note.photos, 0) { viewing = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What the editor needs to file a new note: the lesson it opened from and the next one. */
|
/** What the editor needs to file a new note: the lesson it opened from and the next one. */
|
||||||
@@ -182,11 +213,19 @@ fun NoteEditor(
|
|||||||
val general = editing?.general ?: (target?.subject?.isBlank() == true)
|
val general = editing?.general ?: (target?.subject?.isBlank() == true)
|
||||||
var day by rememberSaveable { mutableStateOf((editing?.date?.let(::parseDate) ?: target?.date ?: today()).toEpochDay()) }
|
var day by rememberSaveable { mutableStateOf((editing?.date?.let(::parseDate) ?: target?.date ?: today()).toEpochDay()) }
|
||||||
var picking by remember { mutableStateOf(false) }
|
var picking by remember { mutableStateOf(false) }
|
||||||
|
// Photos of this edit; the ones added here go from the disk again if it is not saved.
|
||||||
|
var photos by rememberSaveable { mutableStateOf(editing?.photos.orEmpty()) }
|
||||||
|
var added by rememberSaveable { mutableStateOf(emptyList<String>()) }
|
||||||
|
val store = LocalContext.current.app.notes
|
||||||
|
val cancel = {
|
||||||
|
store.discard(added)
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
val focus = remember { FocusRequester() }
|
val focus = remember { FocusRequester() }
|
||||||
LaunchedEffect(Unit) { focus.requestFocus() }
|
LaunchedEffect(Unit) { focus.requestFocus() }
|
||||||
|
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = cancel,
|
||||||
icon = { Icon(if (task) Icons.AutoMirrored.Rounded.Assignment else Icons.Rounded.StickyNote2, contentDescription = null) },
|
icon = { Icon(if (task) Icons.AutoMirrored.Rounded.Assignment else Icons.Rounded.StickyNote2, contentDescription = null) },
|
||||||
title = {
|
title = {
|
||||||
Text(
|
Text(
|
||||||
@@ -198,7 +237,7 @@ fun NoteEditor(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
text = {
|
text = {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.verticalScroll(rememberScrollState())) {
|
||||||
ConnectedChoice(
|
ConnectedChoice(
|
||||||
options = listOf(true to "Задание", false to "Заметка"),
|
options = listOf(true to "Задание", false to "Заметка"),
|
||||||
selected = task,
|
selected = task,
|
||||||
@@ -212,6 +251,19 @@ fun NoteEditor(
|
|||||||
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences),
|
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences),
|
||||||
modifier = Modifier.fillMaxWidth().heightIn(min = 96.dp).focusRequester(focus),
|
modifier = Modifier.fillMaxWidth().heightIn(min = 96.dp).focusRequester(focus),
|
||||||
)
|
)
|
||||||
|
// A photo of the board or the assignment instead of copying it out.
|
||||||
|
NotePhotosEditor(
|
||||||
|
photos = photos,
|
||||||
|
onAdd = { photos = photos + it; added = added + it },
|
||||||
|
onRemove = { name ->
|
||||||
|
photos = photos - name
|
||||||
|
// A photo added in this edit has no saved note to keep it.
|
||||||
|
if (name in added) {
|
||||||
|
added = added - name
|
||||||
|
store.discard(listOf(name))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
if (general) {
|
if (general) {
|
||||||
Text("День", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
Text("День", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
OutlinedButton(onClick = { picking = true }, modifier = Modifier.fillMaxWidth()) {
|
OutlinedButton(onClick = { picking = true }, modifier = Modifier.fillMaxWidth()) {
|
||||||
@@ -241,7 +293,8 @@ fun NoteEditor(
|
|||||||
},
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
TextButton(
|
TextButton(
|
||||||
enabled = text.isNotBlank(),
|
// A photo alone is a note too: "задание на фото".
|
||||||
|
enabled = text.isNotBlank() || photos.isNotEmpty(),
|
||||||
onClick = {
|
onClick = {
|
||||||
val base = editing ?: run {
|
val base = editing ?: run {
|
||||||
val t = target ?: return@TextButton
|
val t = target ?: return@TextButton
|
||||||
@@ -249,16 +302,16 @@ fun NoteEditor(
|
|||||||
Note(group = groupKey, subject = t.subject, date = date.toString(), start = start, text = "")
|
Note(group = groupKey, subject = t.subject, date = date.toString(), start = start, text = "")
|
||||||
}
|
}
|
||||||
val dated = if (general) base.copy(date = LocalDate.ofEpochDay(day).toString()) else base
|
val dated = if (general) base.copy(date = LocalDate.ofEpochDay(day).toString()) else base
|
||||||
onSave(dated.copy(text = text.trim(), task = task))
|
onSave(dated.copy(text = text.trim().ifEmpty { "Фото" }, task = task, photos = photos))
|
||||||
},
|
},
|
||||||
) { Text("Сохранить") }
|
) { Text("Сохранить") }
|
||||||
},
|
},
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
Row {
|
Row {
|
||||||
if (editing != null) TextButton(onClick = { onDelete(editing) }) {
|
if (editing != null) TextButton(onClick = { store.discard(added); onDelete(editing) }) {
|
||||||
Text("Удалить", color = MaterialTheme.colorScheme.error)
|
Text("Удалить", color = MaterialTheme.colorScheme.error)
|
||||||
}
|
}
|
||||||
TextButton(onClick = onDismiss) { Text("Отмена") }
|
TextButton(onClick = cancel) { Text("Отмена") }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -280,6 +333,7 @@ fun NoteEditor(
|
|||||||
/** The lesson sheet's section: this lesson's tasks and notes and a button to add one. */
|
/** The lesson sheet's section: this lesson's tasks and notes and a button to add one. */
|
||||||
@Composable
|
@Composable
|
||||||
fun LessonNotes(vm: MainViewModel, lesson: Lesson, date: LocalDate) {
|
fun LessonNotes(vm: MainViewModel, lesson: Lesson, date: LocalDate) {
|
||||||
|
val undo = LocalUndo.current
|
||||||
val all by vm.notes.collectAsStateWithLifecycle()
|
val all by vm.notes.collectAsStateWithLifecycle()
|
||||||
val timetable by vm.timetable.collectAsStateWithLifecycle()
|
val timetable by vm.timetable.collectAsStateWithLifecycle()
|
||||||
val settings by vm.settings.collectAsStateWithLifecycle()
|
val settings by vm.settings.collectAsStateWithLifecycle()
|
||||||
@@ -317,7 +371,7 @@ fun LessonNotes(vm: MainViewModel, lesson: Lesson, date: LocalDate) {
|
|||||||
editing = editing,
|
editing = editing,
|
||||||
groupKey = group,
|
groupKey = group,
|
||||||
onSave = { vm.saveNote(it); adding = false; editing = null },
|
onSave = { vm.saveNote(it); adding = false; editing = null },
|
||||||
onDelete = { vm.deleteNote(it.id); editing = null },
|
onDelete = { vm.deleteNote(it.id, undo); editing = null },
|
||||||
onDismiss = { adding = false; editing = null },
|
onDismiss = { adding = false; editing = null },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -342,6 +396,11 @@ fun NotesHint(notes: List<Note>, color: androidx.compose.ui.graphics.Color) {
|
|||||||
color = color,
|
color = color,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f, fill = false),
|
||||||
)
|
)
|
||||||
|
if (open.any { it.photos.isNotEmpty() }) {
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Icon(Icons.Rounded.Image, contentDescription = "С фото", tint = color, modifier = Modifier.size(18.dp))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
|
|||||||
import androidx.compose.material.icons.rounded.CalendarMonth
|
import androidx.compose.material.icons.rounded.CalendarMonth
|
||||||
import androidx.compose.material.icons.rounded.Checklist
|
import androidx.compose.material.icons.rounded.Checklist
|
||||||
import androidx.compose.material.icons.rounded.Groups
|
import androidx.compose.material.icons.rounded.Groups
|
||||||
|
import androidx.compose.material.icons.rounded.Check
|
||||||
|
import org.xtimms.psu.schedule.data.choiceKey
|
||||||
import androidx.compose.material.icons.rounded.NotificationsActive
|
import androidx.compose.material.icons.rounded.NotificationsActive
|
||||||
import androidx.compose.material.icons.rounded.DownloadForOffline
|
import androidx.compose.material.icons.rounded.DownloadForOffline
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
@@ -93,14 +95,15 @@ import org.xtimms.psu.schedule.ui.theme.page
|
|||||||
import kotlin.coroutines.cancellation.CancellationException
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
|
|
||||||
/** The first run, in the order a student needs it: what this is, their group, their subgroup, reminders. */
|
/** The first run, in the order a student needs it: what this is, their group, their subgroup, reminders. */
|
||||||
private enum class Step { Welcome, Group, Subgroup, Reminders }
|
private enum class Step { Welcome, Group, Subgroup, Choice, Foreign, Reminders }
|
||||||
|
|
||||||
/** Text and controls of the onboarding pages keep to a phone's width on tablets. */
|
/** Text and controls of the onboarding pages keep to a phone's width on tablets. */
|
||||||
private val PAGE_WIDTH = 560.dp
|
private val PAGE_WIDTH = 560.dp
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* First-run onboarding. Only the group is required: the welcome leads straight to it,
|
* First-run onboarding. Only the group is required: the welcome leads straight to it,
|
||||||
* the subgroup page appears only for groups split into subgroups, and reminders can wait.
|
* the subgroup page appears only for groups split into subgroups, the choice page only for groups
|
||||||
|
* whose parts study one subject or another (German or French), and reminders can wait.
|
||||||
* [onDone] opens the overview with the chosen group.
|
* [onDone] opens the overview with the chosen group.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
@@ -108,14 +111,18 @@ fun Onboarding(vm: MainViewModel, onDone: () -> Unit) {
|
|||||||
val timetable by vm.timetable.collectAsStateWithLifecycle()
|
val timetable by vm.timetable.collectAsStateWithLifecycle()
|
||||||
val settings by vm.settings.collectAsStateWithLifecycle()
|
val settings by vm.settings.collectAsStateWithLifecycle()
|
||||||
val subgroups = timetable?.subgroups.orEmpty()
|
val subgroups = timetable?.subgroups.orEmpty()
|
||||||
|
val choices = timetable?.choices.orEmpty()
|
||||||
var step by rememberSaveable { mutableStateOf(Step.Welcome) }
|
var step by rememberSaveable { mutableStateOf(Step.Welcome) }
|
||||||
// The steps the progress bar counts; the welcome is not one of them.
|
// The steps the progress bar counts; the welcome is not one of them.
|
||||||
val steps = if (subgroups.isEmpty()) listOf(Step.Group, Step.Reminders) else listOf(Step.Group, Step.Subgroup, Step.Reminders)
|
val steps = listOfNotNull(
|
||||||
fun previous(of: Step): Step = when (of) {
|
Step.Group,
|
||||||
Step.Welcome, Step.Group -> Step.Welcome
|
Step.Subgroup.takeIf { subgroups.isNotEmpty() },
|
||||||
Step.Subgroup -> Step.Group
|
Step.Choice.takeIf { choices.isNotEmpty() },
|
||||||
Step.Reminders -> if (subgroups.isEmpty()) Step.Group else Step.Subgroup
|
Step.Foreign.takeIf { timetable?.audience == true },
|
||||||
}
|
Step.Reminders,
|
||||||
|
)
|
||||||
|
fun previous(of: Step): Step = if (of == Step.Welcome) Step.Welcome else steps.getOrNull(steps.indexOf(of) - 1) ?: Step.Welcome
|
||||||
|
fun next(of: Step): Step = steps.getOrNull(steps.indexOf(of) + 1) ?: Step.Reminders
|
||||||
|
|
||||||
// Predictive back, as in the rest of the app: the gesture scrubs the way back to the previous step.
|
// Predictive back, as in the rest of the app: the gesture scrubs the way back to the previous step.
|
||||||
val state = remember { SeekableTransitionState(step) }
|
val state = remember { SeekableTransitionState(step) }
|
||||||
@@ -162,7 +169,16 @@ fun Onboarding(vm: MainViewModel, onDone: () -> Unit) {
|
|||||||
Step.Welcome -> Welcome(onStart = { step = Step.Group })
|
Step.Welcome -> Welcome(onStart = { step = Step.Group })
|
||||||
Step.Group -> GroupsScreen(
|
Step.Group -> GroupsScreen(
|
||||||
vm,
|
vm,
|
||||||
onOpened = { step = if (vm.timetable.value?.subgroups.isNullOrEmpty()) Step.Reminders else Step.Subgroup },
|
// The steps above still count the previous group's timetable: read the new one.
|
||||||
|
onOpened = {
|
||||||
|
val t = vm.timetable.value
|
||||||
|
step = when {
|
||||||
|
!t?.subgroups.isNullOrEmpty() -> Step.Subgroup
|
||||||
|
!t?.choices.isNullOrEmpty() -> Step.Choice
|
||||||
|
t?.audience == true -> Step.Foreign
|
||||||
|
else -> Step.Reminders
|
||||||
|
}
|
||||||
|
},
|
||||||
onBack = null,
|
onBack = null,
|
||||||
onboarding = true,
|
onboarding = true,
|
||||||
)
|
)
|
||||||
@@ -170,7 +186,18 @@ fun Onboarding(vm: MainViewModel, onDone: () -> Unit) {
|
|||||||
group = timetable?.group.orEmpty(),
|
group = timetable?.group.orEmpty(),
|
||||||
subgroups = subgroups,
|
subgroups = subgroups,
|
||||||
selected = settings?.subgroup,
|
selected = settings?.subgroup,
|
||||||
onPick = { vm.setSubgroup(it); step = Step.Reminders },
|
onPick = { vm.setSubgroup(it); step = next(Step.Subgroup) },
|
||||||
|
)
|
||||||
|
Step.Choice -> ChoiceStep(
|
||||||
|
group = timetable?.group.orEmpty(),
|
||||||
|
choices = choices,
|
||||||
|
taken = settings?.choices.orEmpty(),
|
||||||
|
onPick = vm::setChoice,
|
||||||
|
onNext = { step = next(Step.Choice) },
|
||||||
|
)
|
||||||
|
Step.Foreign -> ForeignStep(
|
||||||
|
group = timetable?.group.orEmpty(),
|
||||||
|
onPick = { vm.setForeign(it); step = Step.Reminders },
|
||||||
)
|
)
|
||||||
Step.Reminders -> RemindersStep(
|
Step.Reminders -> RemindersStep(
|
||||||
minutes = settings?.reminderMinutes ?: 10,
|
minutes = settings?.reminderMinutes ?: 10,
|
||||||
@@ -441,6 +468,75 @@ private fun SubgroupStep(group: String, subgroups: List<String>, selected: Strin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Что вы изучаете": German or French, say. With one choice a tap picks and moves on; with
|
||||||
|
* several, each list keeps its pick and "Далее" moves on.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun ChoiceStep(
|
||||||
|
group: String,
|
||||||
|
choices: List<List<String>>,
|
||||||
|
taken: Map<String, String>,
|
||||||
|
onPick: (List<String>, String?) -> Unit,
|
||||||
|
onNext: () -> Unit,
|
||||||
|
) {
|
||||||
|
StepPage(
|
||||||
|
centered = false,
|
||||||
|
buttons = {
|
||||||
|
if (choices.size > 1) PrimaryButton("Далее", onNext)
|
||||||
|
TextButton(onClick = onNext) { Text("Пропустить") }
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
StepHeading(Icons.Rounded.Groups, "Что вы изучаете?", choiceText(group, choices.first()))
|
||||||
|
choices.forEachIndexed { n, options ->
|
||||||
|
if (n > 0) Spacer(Modifier.height(16.dp))
|
||||||
|
SegmentedList {
|
||||||
|
val all = options + listOf<String?>(null)
|
||||||
|
all.forEachIndexed { i, option ->
|
||||||
|
SegmentedItem(
|
||||||
|
index = i,
|
||||||
|
count = all.size,
|
||||||
|
headline = option ?: "Показывать всё",
|
||||||
|
supporting = if (option == null) "Занятия всех вариантов" else null,
|
||||||
|
onClick = {
|
||||||
|
onPick(options, option)
|
||||||
|
if (choices.size == 1) onNext()
|
||||||
|
},
|
||||||
|
trailing = {
|
||||||
|
if (choices.size > 1 && taken[choiceKey(options)] == option) Icon(Icons.Rounded.Check, contentDescription = "Выбрано")
|
||||||
|
else Icon(Icons.AutoMirrored.Rounded.KeyboardArrowRight, contentDescription = null)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Вы иностранный студент?" as a page: one tap answers and moves on. */
|
||||||
|
@Composable
|
||||||
|
private fun ForeignStep(group: String, onPick: (Boolean?) -> Unit) {
|
||||||
|
StepPage(
|
||||||
|
centered = false,
|
||||||
|
buttons = { TextButton(onClick = { onPick(null) }) { Text("Пропустить") } },
|
||||||
|
) {
|
||||||
|
StepHeading(Icons.Rounded.Groups, "Вы иностранный студент?", foreignText(group))
|
||||||
|
SegmentedList {
|
||||||
|
val answers = listOf<Pair<Boolean?, String>>(true to "Да, иностранный", false to "Нет", null to "Показывать всё")
|
||||||
|
answers.forEachIndexed { i, (value, label) ->
|
||||||
|
SegmentedItem(
|
||||||
|
index = i,
|
||||||
|
count = answers.size,
|
||||||
|
headline = label,
|
||||||
|
supporting = if (value == null) "Пары для всех студентов" else null,
|
||||||
|
onClick = { onPick(value) },
|
||||||
|
trailing = { Icon(Icons.AutoMirrored.Rounded.KeyboardArrowRight, contentDescription = null) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Reminders before each lesson; Android asks for the notification permission here, when it makes sense. */
|
/** Reminders before each lesson; Android asks for the notification permission here, when it makes sense. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun RemindersStep(minutes: Int, onMinutes: (Int) -> Unit, onDone: (Boolean) -> Unit) {
|
private fun RemindersStep(minutes: Int, onMinutes: (Int) -> Unit, onDone: (Boolean) -> Unit) {
|
||||||
|
|||||||
274
android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/Photos.kt
Normal file
274
android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/Photos.kt
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
package org.xtimms.psu.schedule.ui
|
||||||
|
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.net.Uri
|
||||||
|
import android.util.LruCache
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.PickVisualMediaRequest
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
|
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||||
|
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||||
|
import androidx.compose.foundation.gestures.calculatePan
|
||||||
|
import androidx.compose.foundation.gestures.calculateZoom
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.rounded.Close
|
||||||
|
import androidx.compose.material.icons.rounded.PhotoCamera
|
||||||
|
import androidx.compose.material.icons.rounded.PhotoLibrary
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.FilledIconButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.IconButtonDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.input.pointer.positionChanged
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.xtimms.psu.schedule.app
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
// Photos of the board or the assignment, pinned to a task or a note (NotesStore keeps the files).
|
||||||
|
|
||||||
|
/** Decoded thumbnails, so lists scrolling past the same photos do not decode them again. */
|
||||||
|
private val thumbnails = object : LruCache<String, ImageBitmap>(24 * 1024 * 1024) {
|
||||||
|
override fun sizeOf(key: String, value: ImageBitmap) = value.width * value.height * 4
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A photo decoded no larger than [max] pixels on its long side; null while it loads. */
|
||||||
|
@Composable
|
||||||
|
private fun rememberPhoto(file: File, max: Int): ImageBitmap? {
|
||||||
|
val key = "${file.name}@$max"
|
||||||
|
return produceState(thumbnails.get(key), key) {
|
||||||
|
if (value != null) return@produceState
|
||||||
|
value = withContext(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||||
|
BitmapFactory.decodeFile(file.path, bounds)
|
||||||
|
var sample = 1
|
||||||
|
while (maxOf(bounds.outWidth, bounds.outHeight) / (sample * 2) >= max) sample *= 2
|
||||||
|
BitmapFactory.decodeFile(file.path, BitmapFactory.Options().apply { inSampleSize = sample })?.asImageBitmap()
|
||||||
|
}.getOrNull()
|
||||||
|
}?.also { thumbnails.put(key, it) }
|
||||||
|
}.value
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A square thumbnail; [onClick] opens the viewer. */
|
||||||
|
@Composable
|
||||||
|
fun PhotoThumb(name: String, size: Dp, onClick: (() -> Unit)?, modifier: Modifier = Modifier) {
|
||||||
|
val store = LocalContext.current.app.notes
|
||||||
|
val px = with(LocalDensity.current) { size.roundToPx() } * 2
|
||||||
|
val image = rememberPhoto(store.photo(name), px)
|
||||||
|
Box(
|
||||||
|
modifier
|
||||||
|
.size(size)
|
||||||
|
.clip(MaterialTheme.shapes.medium)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
|
||||||
|
.then(if (onClick != null) Modifier.clickable(onClickLabel = "Открыть фото", onClick = onClick) else Modifier),
|
||||||
|
) {
|
||||||
|
image?.let { Image(it, contentDescription = "Фото", contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The editor's photos: thumbnails with a remove button, then "Снять" (the camera app) and
|
||||||
|
* "Галерея" (the system photo picker, no storage permission needed).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun NotePhotosEditor(photos: List<String>, onAdd: (String) -> Unit, onRemove: (String) -> Unit) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val store = context.app.notes
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var loading by remember { mutableIntStateOf(0) }
|
||||||
|
var viewing by remember { mutableStateOf<Int?>(null) }
|
||||||
|
fun import(uris: List<Uri>) {
|
||||||
|
uris.forEach { uri ->
|
||||||
|
loading++
|
||||||
|
scope.launch {
|
||||||
|
store.importPhoto(uri)?.let(onAdd)
|
||||||
|
loading--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The camera app writes the full-size shot here; the store keeps a smaller copy.
|
||||||
|
val shot = remember { File(context.cacheDir, "camera/shot.jpg") }
|
||||||
|
val camera = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { taken ->
|
||||||
|
if (taken) {
|
||||||
|
loading++
|
||||||
|
scope.launch {
|
||||||
|
store.importPhoto(Uri.fromFile(shot))?.let(onAdd)
|
||||||
|
shot.delete()
|
||||||
|
loading--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val gallery = rememberLauncherForActivityResult(ActivityResultContracts.PickMultipleVisualMedia(MAX_PHOTOS)) { import(it) }
|
||||||
|
val room = MAX_PHOTOS - photos.size
|
||||||
|
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
if (photos.isNotEmpty() || loading > 0) Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
||||||
|
) {
|
||||||
|
photos.forEachIndexed { index, name ->
|
||||||
|
Box {
|
||||||
|
PhotoThumb(name, 72.dp, onClick = { viewing = index })
|
||||||
|
FilledIconButton(
|
||||||
|
onClick = { onRemove(name) },
|
||||||
|
colors = IconButtonDefaults.filledIconButtonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.inverseSurface,
|
||||||
|
contentColor = MaterialTheme.colorScheme.inverseOnSurface,
|
||||||
|
),
|
||||||
|
modifier = Modifier.align(Alignment.TopEnd).padding(4.dp).size(24.dp),
|
||||||
|
) { Icon(Icons.Rounded.Close, contentDescription = "Убрать фото", modifier = Modifier.size(16.dp)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
repeat(loading) {
|
||||||
|
Box(
|
||||||
|
Modifier.size(72.dp).clip(MaterialTheme.shapes.medium).background(MaterialTheme.colorScheme.surfaceContainerHighest),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) { CircularProgressIndicator(Modifier.size(24.dp), strokeWidth = 2.dp) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (room > 0) Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = {
|
||||||
|
shot.parentFile?.mkdirs()
|
||||||
|
val uri = FileProvider.getUriForFile(context, context.packageName + ".photos", shot)
|
||||||
|
// A phone without a camera app: the gallery is still there.
|
||||||
|
runCatching { camera.launch(uri) }
|
||||||
|
},
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Rounded.PhotoCamera, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text("Снять", maxLines = 1)
|
||||||
|
}
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { gallery.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Rounded.PhotoLibrary, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text("Галерея", maxLines = 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
viewing?.let { PhotoViewer(photos, it) { viewing = null } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Photos full screen, swiped sideways; pinch or double-tap to zoom into the board. */
|
||||||
|
@Composable
|
||||||
|
fun PhotoViewer(photos: List<String>, start: Int, onDismiss: () -> Unit) {
|
||||||
|
if (photos.isEmpty()) return
|
||||||
|
val store = LocalContext.current.app.notes
|
||||||
|
Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) {
|
||||||
|
val pager = rememberPagerState(initialPage = start.coerceIn(0, photos.lastIndex)) { photos.size }
|
||||||
|
Box(Modifier.fillMaxSize().background(Color.Black)) {
|
||||||
|
HorizontalPager(state = pager, modifier = Modifier.fillMaxSize()) { page ->
|
||||||
|
var scale by remember { mutableFloatStateOf(1f) }
|
||||||
|
var offset by remember { mutableStateOf(Offset.Zero) }
|
||||||
|
val image = rememberPhoto(store.photo(photos[page]), 2048)
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
detectTapGestures(onDoubleTap = {
|
||||||
|
if (scale > 1f) { scale = 1f; offset = Offset.Zero } else scale = 2.5f
|
||||||
|
})
|
||||||
|
}
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
// Two fingers zoom; one finger pans only while zoomed in, so at 1x the
|
||||||
|
// pager still takes the swipe to the next photo.
|
||||||
|
awaitEachGesture {
|
||||||
|
awaitFirstDown(requireUnconsumed = false)
|
||||||
|
do {
|
||||||
|
val event = awaitPointerEvent()
|
||||||
|
if (event.changes.size > 1 || scale > 1f) {
|
||||||
|
scale = (scale * event.calculateZoom()).coerceIn(1f, 5f)
|
||||||
|
offset = if (scale > 1f) offset + event.calculatePan() else Offset.Zero
|
||||||
|
event.changes.forEach { if (it.positionChanged()) it.consume() }
|
||||||
|
}
|
||||||
|
} while (event.changes.any { it.pressed })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
if (image == null) CircularProgressIndicator(color = Color.White)
|
||||||
|
else Image(
|
||||||
|
image,
|
||||||
|
contentDescription = "Фото ${page + 1} из ${photos.size}",
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
modifier = Modifier.fillMaxSize().graphicsLayer {
|
||||||
|
scaleX = scale
|
||||||
|
scaleY = scale
|
||||||
|
translationX = offset.x
|
||||||
|
translationY = offset.y
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth().statusBarsPadding().padding(8.dp),
|
||||||
|
) {
|
||||||
|
IconButton(onClick = onDismiss) { Icon(Icons.Rounded.Close, contentDescription = "Закрыть", tint = Color.White) }
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
if (photos.size > 1) Surface(shape = CircleShape, color = Color.Black.copy(alpha = 0.5f), contentColor = Color.White) {
|
||||||
|
Text("${pager.currentPage + 1} / ${photos.size}", style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Up to this many photos per task or note. */
|
||||||
|
const val MAX_PHOTOS = 6
|
||||||
@@ -28,6 +28,7 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
@@ -119,22 +120,33 @@ private sealed interface Row {
|
|||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun PlansScreen(vm: MainViewModel, nav: AppNav) {
|
fun PlansScreen(vm: MainViewModel, nav: AppNav) {
|
||||||
|
val undo = LocalUndo.current
|
||||||
val all by vm.notes.collectAsStateWithLifecycle()
|
val all by vm.notes.collectAsStateWithLifecycle()
|
||||||
val timetable by vm.timetable.collectAsStateWithLifecycle()
|
val timetable by vm.timetable.collectAsStateWithLifecycle()
|
||||||
val settings by vm.settings.collectAsStateWithLifecycle()
|
val settings by vm.settings.collectAsStateWithLifecycle()
|
||||||
val prefs = settings?.plans ?: PlansPrefs()
|
val prefs = settings?.plans ?: PlansPrefs()
|
||||||
var editing by remember { mutableStateOf<Note?>(null) }
|
var editing by remember { mutableStateOf<Note?>(null) }
|
||||||
var adding by remember { mutableStateOf(false) }
|
var target by remember { mutableStateOf<NoteTarget?>(null) }
|
||||||
|
// "+" grows into the sheet of a new plan (Morph.kt); a shortcut opens it from the middle.
|
||||||
|
val morph = LocalMorph.current
|
||||||
|
val addPlan = {
|
||||||
|
morph?.open(MorphKind.Sheet) { close ->
|
||||||
|
AddPlanSheet(vm, timetable, settings?.subgroup, day = null) {
|
||||||
|
close()
|
||||||
|
target = it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Unit
|
||||||
|
}
|
||||||
val wantsTask by vm.newTask.collectAsStateWithLifecycle()
|
val wantsTask by vm.newTask.collectAsStateWithLifecycle()
|
||||||
LaunchedEffect(wantsTask) {
|
LaunchedEffect(wantsTask) {
|
||||||
if (wantsTask) {
|
if (wantsTask) {
|
||||||
adding = true
|
addPlan()
|
||||||
vm.newTask.value = false
|
vm.newTask.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var tuning by remember { mutableStateOf(false) }
|
var tuning by remember { mutableStateOf(false) }
|
||||||
var details by remember { mutableStateOf<Pair<Lesson, LocalDate>?>(null) }
|
val openLesson = rememberLessonOpener(vm)
|
||||||
var target by remember { mutableStateOf<NoteTarget?>(null) }
|
|
||||||
var date by remember { mutableStateOf(today()) }
|
var date by remember { mutableStateOf(today()) }
|
||||||
// Each layout keeps its own list, so the outgoing and incoming layouts can be on screen
|
// Each layout keeps its own list, so the outgoing and incoming layouts can be on screen
|
||||||
// together while one fades through to the other.
|
// together while one fades through to the other.
|
||||||
@@ -174,11 +186,15 @@ fun PlansScreen(vm: MainViewModel, nav: AppNav) {
|
|||||||
title = {
|
title = {
|
||||||
Column {
|
Column {
|
||||||
Text("Планы", style = MaterialTheme.typography.titleLargeEmphasized)
|
Text("Планы", style = MaterialTheme.typography.titleLargeEmphasized)
|
||||||
Text(
|
androidx.compose.foundation.layout.Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
if (open == 0) "Всё сделано" else "$open ${plural(open, "дело", "дела", "дел")} впереди",
|
// The last task done: a check pops up by "Всё сделано".
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
DoneMark(open == 0)
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
Text(
|
||||||
)
|
if (open == 0) "Всё сделано" else "$open ${plural(open, "дело", "дела", "дел")} впереди",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
@@ -254,7 +270,7 @@ fun PlansScreen(vm: MainViewModel, nav: AppNav) {
|
|||||||
DetailPane(it) {
|
DetailPane(it) {
|
||||||
DayAgenda(
|
DayAgenda(
|
||||||
vm, timetable, settings?.subgroup, date, all,
|
vm, timetable, settings?.subgroup, date, all,
|
||||||
onLesson = { details = it to date },
|
onLesson = { openLesson(it, date) },
|
||||||
onNote = { editing = it },
|
onNote = { editing = it },
|
||||||
modifier = Modifier.fillMaxSize().padding(top = 12.dp),
|
modifier = Modifier.fillMaxSize().padding(top = 12.dp),
|
||||||
)
|
)
|
||||||
@@ -265,30 +281,22 @@ fun PlansScreen(vm: MainViewModel, nav: AppNav) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
FloatingActionButton(
|
FloatingActionButton(
|
||||||
onClick = { adding = true },
|
onClick = addPlan,
|
||||||
modifier = Modifier.align(Alignment.BottomEnd).padding(end = 16.dp, bottom = 16.dp + navBarPadding()),
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomEnd)
|
||||||
|
.padding(end = 16.dp, bottom = 16.dp + navBarPadding())
|
||||||
|
.morphOrigin("fab|plans", MaterialTheme.colorScheme.primaryContainer, 16.dp),
|
||||||
) { Icon(Icons.Rounded.Add, contentDescription = "Новое задание") }
|
) { Icon(Icons.Rounded.Add, contentDescription = "Новое задание") }
|
||||||
}
|
}
|
||||||
|
|
||||||
details?.let { (lesson, d) ->
|
|
||||||
ModalBottomSheet(onDismissRequest = { details = null }) {
|
|
||||||
LessonDetails(lesson, timetable?.bells.orEmpty(), d, vm, onClose = { details = null })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (tuning) ModalBottomSheet(onDismissRequest = { tuning = false }) { PlansSettings(prefs, vm::setPlans) }
|
if (tuning) ModalBottomSheet(onDismissRequest = { tuning = false }) { PlansSettings(prefs, vm::setPlans) }
|
||||||
val group = vm.currentGroupKey()
|
val group = vm.currentGroupKey()
|
||||||
if (adding) ModalBottomSheet(onDismissRequest = { adding = false }) {
|
|
||||||
AddPlanSheet(vm, timetable, settings?.subgroup, day = null) {
|
|
||||||
adding = false
|
|
||||||
target = it
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (group != null && (target != null || editing != null)) NoteEditor(
|
if (group != null && (target != null || editing != null)) NoteEditor(
|
||||||
target = target,
|
target = target,
|
||||||
editing = editing,
|
editing = editing,
|
||||||
groupKey = editing?.group ?: group,
|
groupKey = editing?.group ?: group,
|
||||||
onSave = { vm.saveNote(it); target = null; editing = null },
|
onSave = { vm.saveNote(it); target = null; editing = null },
|
||||||
onDelete = { vm.deleteNote(it.id); editing = null },
|
onDelete = { vm.deleteNote(it.id, undo); editing = null },
|
||||||
onDismiss = { target = null; editing = null },
|
onDismiss = { target = null; editing = null },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -564,6 +572,7 @@ internal fun AddPlanSheet(vm: MainViewModel, t: Timetable?, subgroup: String?, d
|
|||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(rememberScrollState())
|
||||||
|
.navigationBarsPadding()
|
||||||
.padding(start = 24.dp, end = 24.dp, bottom = 32.dp),
|
.padding(start = 24.dp, end = 24.dp, bottom = 32.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
package org.xtimms.psu.schedule.ui
|
package org.xtimms.psu.schedule.ui
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import kotlinx.coroutines.flow.drop
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.animation.togetherWith
|
import androidx.compose.animation.togetherWith
|
||||||
import androidx.compose.animation.Crossfade
|
import androidx.compose.animation.Crossfade
|
||||||
@@ -19,6 +23,7 @@ import androidx.compose.foundation.interaction.collectIsDraggedAsState
|
|||||||
import androidx.compose.material.icons.rounded.VisibilityOff
|
import androidx.compose.material.icons.rounded.VisibilityOff
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import org.xtimms.psu.schedule.data.hideKey
|
import org.xtimms.psu.schedule.data.hideKey
|
||||||
|
import org.xtimms.psu.schedule.data.hiddenIn
|
||||||
import androidx.compose.runtime.snapshotFlow
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
@@ -33,6 +38,7 @@ import androidx.compose.foundation.background
|
|||||||
import androidx.compose.foundation.layout.widthIn
|
import androidx.compose.foundation.layout.widthIn
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
|
import androidx.compose.foundation.layout.systemBars
|
||||||
import androidx.compose.foundation.layout.statusBars
|
import androidx.compose.foundation.layout.statusBars
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.material.icons.rounded.Today
|
import androidx.compose.material.icons.rounded.Today
|
||||||
@@ -81,16 +87,15 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
|||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.LoadingIndicator
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.ModalBottomSheet
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -128,12 +133,12 @@ fun ScheduleScreen(
|
|||||||
val timetable by vm.timetable.collectAsStateWithLifecycle()
|
val timetable by vm.timetable.collectAsStateWithLifecycle()
|
||||||
val settings by vm.settings.collectAsStateWithLifecycle()
|
val settings by vm.settings.collectAsStateWithLifecycle()
|
||||||
val status by vm.status.collectAsStateWithLifecycle()
|
val status by vm.status.collectAsStateWithLifecycle()
|
||||||
var details by remember { mutableStateOf<Pair<Lesson, LocalDate?>?>(null) }
|
val openLesson = rememberLessonOpener(vm)
|
||||||
val subgroup = settings?.subgroup
|
val subgroup = settings?.subgroup
|
||||||
val t = timetable
|
val t = timetable
|
||||||
|
|
||||||
if (t?.semester != null) {
|
if (t?.semester != null) {
|
||||||
Planner(vm, nav, t, subgroup, weekView, onWeekView, focus, onFocusHandled, onOpen = { lesson, date -> details = lesson to date })
|
Planner(vm, nav, t, subgroup, weekView, onWeekView, focus, onFocusHandled, onOpen = { lesson, date -> openLesson(lesson, date) })
|
||||||
} else Column(Modifier.fillMaxSize()) {
|
} else Column(Modifier.fillMaxSize()) {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
colors = appBarColors(),
|
colors = appBarColors(),
|
||||||
@@ -143,7 +148,7 @@ fun ScheduleScreen(
|
|||||||
)
|
)
|
||||||
status.message?.let { Banner(it, offline = status.offline, onClose = vm::dismissMessage, onRetry = if (status.offline) vm::retry else null, action = if (status.needsGroup) "Выбрать группу" to nav.openGroups else null) }
|
status.message?.let { Banner(it, offline = status.offline, onClose = vm::dismissMessage, onRetry = if (status.offline) vm::retry else null, action = if (status.needsGroup) "Выбрать группу" to nav.openGroups else null) }
|
||||||
when {
|
when {
|
||||||
t == null && status.loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { LoadingIndicator() }
|
t == null && status.loading -> ScheduleSkeleton()
|
||||||
t == null -> CenteredEmpty {
|
t == null -> CenteredEmpty {
|
||||||
Empty(
|
Empty(
|
||||||
"Расписание не загружено",
|
"Расписание не загружено",
|
||||||
@@ -153,12 +158,7 @@ fun ScheduleScreen(
|
|||||||
action = if (settings?.current != null) "Повторить" to { vm.refresh(); Unit } else "Выбрать группу" to nav.openGroups,
|
action = if (settings?.current != null) "Повторить" to { vm.refresh(); Unit } else "Выбрать группу" to nav.openGroups,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
else -> WordRows(t, subgroup, settings?.hidden.orEmpty(), onOpen = { details = it to null })
|
else -> WordRows(t, subgroup, settings?.hidden.orEmpty(), onOpen = { openLesson(it, null) })
|
||||||
}
|
|
||||||
}
|
|
||||||
details?.let { (lesson, date) ->
|
|
||||||
ModalBottomSheet(onDismissRequest = { details = null }) {
|
|
||||||
LessonDetails(lesson, timetable?.bells.orEmpty(), date, vm, onClose = { details = null })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -233,6 +233,17 @@ private fun Planner(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A finger swipe to another day ticks like a tap on the strip.
|
||||||
|
val haptic = LocalHapticFeedback.current
|
||||||
|
val draggingDays by pager.interactionSource.collectIsDraggedAsState()
|
||||||
|
var daySwiped by remember { mutableStateOf(false) }
|
||||||
|
LaunchedEffect(draggingDays) { if (draggingDays) daySwiped = true }
|
||||||
|
LaunchedEffect(pager) {
|
||||||
|
snapshotFlow { pager.settledPage }.drop(1).collect {
|
||||||
|
if (daySwiped) haptic.performHapticFeedback(HapticFeedbackType.SegmentTick)
|
||||||
|
daySwiped = false
|
||||||
|
}
|
||||||
|
}
|
||||||
// The strip is a pager of weeks: it follows the day pager, and swiping it moves the days.
|
// The strip is a pager of weeks: it follows the day pager, and swiping it moves the days.
|
||||||
val strip = rememberPagerState(initialPage = mondays.indexOf(date.monday()).coerceAtLeast(0)) { mondays.size }
|
val strip = rememberPagerState(initialPage = mondays.indexOf(date.monday()).coerceAtLeast(0)) { mondays.size }
|
||||||
LaunchedEffect(date.monday()) {
|
LaunchedEffect(date.monday()) {
|
||||||
@@ -336,6 +347,7 @@ private fun Planner(
|
|||||||
enabled = { d -> dates.contains(d) },
|
enabled = { d -> dates.contains(d) },
|
||||||
onSelect = ::go,
|
onSelect = ::go,
|
||||||
onWeek = ::week,
|
onWeek = ::week,
|
||||||
|
marks = true,
|
||||||
modifier = Modifier.widthIn(max = MAX_CONTENT).padding(horizontal = 8.dp),
|
modifier = Modifier.widthIn(max = MAX_CONTENT).padding(horizontal = 8.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -382,7 +394,8 @@ private fun Planner(
|
|||||||
modifier = if (wide) Modifier.padding(top = 16.dp) else Modifier,
|
modifier = if (wide) Modifier.padding(top = 16.dp) else Modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else PullToRefreshBox(isRefreshing = status.loading, onRefresh = { vm.refresh() }) {
|
} else ExpressiveRefresh(refreshing = status.loading, onRefresh = { vm.refresh() }) {
|
||||||
|
val fresh by vm.fresh.collectAsStateWithLifecycle()
|
||||||
HorizontalPager(state = pager, modifier = Modifier.fillMaxSize()) { page ->
|
HorizontalPager(state = pager, modifier = Modifier.fillMaxSize()) { page ->
|
||||||
val pageDate = dates[page]
|
val pageDate = dates[page]
|
||||||
DayPage(
|
DayPage(
|
||||||
@@ -390,6 +403,8 @@ private fun Planner(
|
|||||||
slots = vm.slotsOn(t, pageDate, subgroup),
|
slots = vm.slotsOn(t, pageDate, subgroup),
|
||||||
places = vm.placesOn(t, pageDate, subgroup),
|
places = vm.placesOn(t, pageDate, subgroup),
|
||||||
onOpen = onOpen,
|
onOpen = onOpen,
|
||||||
|
fresh = fresh,
|
||||||
|
onFreshShown = vm::freshShown,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -437,12 +452,30 @@ private fun Planner(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DayPage(date: LocalDate, slots: List<Slot>, places: List<DayPlace>, onOpen: (Lesson, LocalDate) -> Unit) {
|
private fun DayPage(
|
||||||
|
date: LocalDate,
|
||||||
|
slots: List<Slot>,
|
||||||
|
places: List<DayPlace>,
|
||||||
|
onOpen: (Lesson, LocalDate) -> Unit,
|
||||||
|
fresh: Set<String> = emptySet(),
|
||||||
|
onFreshShown: (String) -> Unit = {},
|
||||||
|
) {
|
||||||
val clock = rememberClock()
|
val clock = rememberClock()
|
||||||
val isToday = date == today()
|
val isToday = date == today()
|
||||||
val next = if (isToday) slots.firstOrNull { it.start.isAfter(clock) } else null
|
val next = if (isToday) slots.firstOrNull { it.start.isAfter(clock) } else null
|
||||||
|
val list = rememberLazyListState()
|
||||||
|
// Today opens on the lesson under way (or the next one), not on the morning's first: one
|
||||||
|
// look tells where to go now. Once per page, so a scroll back up stays where it is.
|
||||||
|
LaunchedEffect(isToday) {
|
||||||
|
if (!isToday) return@LaunchedEffect
|
||||||
|
val now = slots.indexOfFirst { clock.isBefore(it.end) }
|
||||||
|
if (now <= 0) return@LaunchedEffect
|
||||||
|
val before = (if (places.isNotEmpty()) 1 else 0) + slots.take(now).sumOf { it.lessons.size }
|
||||||
|
list.scrollToItem(before)
|
||||||
|
}
|
||||||
ContentColumn {
|
ContentColumn {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
|
state = list,
|
||||||
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 24.dp + navBarPadding()),
|
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 24.dp + navBarPadding()),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
@@ -472,7 +505,11 @@ private fun DayPage(date: LocalDate, slots: List<Slot>, places: List<DayPlace>,
|
|||||||
date = date,
|
date = date,
|
||||||
status = status,
|
status = status,
|
||||||
running = running,
|
running = running,
|
||||||
|
progress = if (running) Duration.between(slot.start, clock).toSeconds().toFloat() /
|
||||||
|
Duration.between(slot.start, slot.end).toSeconds().coerceAtLeast(1) else null,
|
||||||
onClick = { onOpen(lesson, date) },
|
onClick = { onOpen(lesson, date) },
|
||||||
|
fresh = lesson.key in fresh,
|
||||||
|
onFreshShown = { onFreshShown(lesson.key) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -491,7 +528,12 @@ fun LessonCard(
|
|||||||
date: LocalDate? = null,
|
date: LocalDate? = null,
|
||||||
status: String? = null,
|
status: String? = null,
|
||||||
running: Boolean = false,
|
running: Boolean = false,
|
||||||
|
/** How far the lesson under way has gone, 0..1: a line runs round the card. */
|
||||||
|
progress: Float? = null,
|
||||||
onClick: (() -> Unit)?,
|
onClick: (() -> Unit)?,
|
||||||
|
/** Just added or changed by a republished document: the card glows twice, then [onFreshShown]. */
|
||||||
|
fresh: Boolean = false,
|
||||||
|
onFreshShown: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val color = subjectColor(lesson.subject)
|
val color = subjectColor(lesson.subject)
|
||||||
val show = LocalDisplay.current
|
val show = LocalDisplay.current
|
||||||
@@ -509,7 +551,7 @@ fun LessonCard(
|
|||||||
color = if (running) color.onSoft else color.onSoft.copy(alpha = 0.12f),
|
color = if (running) color.onSoft else color.onSoft.copy(alpha = 0.12f),
|
||||||
contentColor = if (running) color.soft else color.onSoft,
|
contentColor = if (running) color.soft else color.onSoft,
|
||||||
) {
|
) {
|
||||||
Text(it, style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp))
|
RollingText(it, style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -527,6 +569,13 @@ fun LessonCard(
|
|||||||
lesson.type.ifEmpty { null }?.takeIf { session == null },
|
lesson.type.ifEmpty { null }?.takeIf { session == null },
|
||||||
if (lesson.variant != "all") (if (lesson.variant == "upper") "белые недели" else "зелёные недели") else null,
|
if (lesson.variant != "all") (if (lesson.variant == "upper") "белые недели" else "зелёные недели") else null,
|
||||||
if (lesson.groups.size > 1) "общее занятие" else null,
|
if (lesson.groups.size > 1) "общее занятие" else null,
|
||||||
|
// German or French: a part of the group has this one, the rest the other.
|
||||||
|
if (lesson.option != null) "на выбор" else null,
|
||||||
|
when (lesson.audience) {
|
||||||
|
"foreign" -> "для иностранных студентов"
|
||||||
|
"local" -> "кроме иностранных студентов"
|
||||||
|
else -> null
|
||||||
|
},
|
||||||
)
|
)
|
||||||
val mark = date?.let { LocalMarks.current.forLesson(lesson.subject, it, lesson.start) }
|
val mark = date?.let { LocalMarks.current.forLesson(lesson.subject, it, lesson.start) }
|
||||||
if (tags.isNotEmpty() || lesson.uncertain || mark != null) FlowRow(
|
if (tags.isNotEmpty() || lesson.uncertain || mark != null) FlowRow(
|
||||||
@@ -544,7 +593,26 @@ fun LessonCard(
|
|||||||
if (date != null) NotesHint(LocalNotes.current.forLesson(lesson.subject, date, lesson.start), color.onSoft)
|
if (date != null) NotesHint(LocalNotes.current.forLesson(lesson.subject, date, lesson.start), color.onSoft)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// A tapped card grows into its details page (Morph.kt).
|
||||||
|
val glow = remember { androidx.compose.animation.core.Animatable(0f) }
|
||||||
|
val reduced = rememberReducedMotion()
|
||||||
|
LaunchedEffect(fresh) {
|
||||||
|
if (!fresh) return@LaunchedEffect
|
||||||
|
if (!reduced) repeat(2) {
|
||||||
|
glow.animateTo(1f, tween(350, easing = EmphasizedDecelerate))
|
||||||
|
glow.animateTo(0f, tween(450))
|
||||||
|
}
|
||||||
|
onFreshShown()
|
||||||
|
}
|
||||||
|
// The fill itself turns tertiary and back, so the text stays as crisp as on any card.
|
||||||
|
val highlight = MaterialTheme.colorScheme.tertiaryContainer
|
||||||
|
val fill = if (glow.value > 0f) androidx.compose.ui.graphics.lerp(color.soft, highlight, glow.value) else color.soft
|
||||||
|
val origin = if (onClick != null) Modifier.morphOrigin("card|$date|${lesson.key}|${lesson.start}", color.soft, 16.dp) else Modifier
|
||||||
|
// The line round a running card grows a little every half minute, never jumps.
|
||||||
|
val shown by androidx.compose.animation.core.animateFloatAsState(progress ?: 0f, tween(1000), label = "progress")
|
||||||
|
val live = if (running && progress != null) Modifier.progressBorder(shown, color.onSoft, 16.dp) else Modifier
|
||||||
val border = when {
|
val border = when {
|
||||||
|
running && progress != null -> null
|
||||||
running -> BorderStroke(2.dp, color.onSoft)
|
running -> BorderStroke(2.dp, color.onSoft)
|
||||||
session != null -> BorderStroke(2.dp, session.outline())
|
session != null -> BorderStroke(2.dp, session.outline())
|
||||||
else -> null
|
else -> null
|
||||||
@@ -552,10 +620,10 @@ fun LessonCard(
|
|||||||
if (onClick != null) Surface(
|
if (onClick != null) Surface(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
shape = MaterialTheme.shapes.large,
|
shape = MaterialTheme.shapes.large,
|
||||||
color = color.soft,
|
color = fill,
|
||||||
contentColor = color.onSoft,
|
contentColor = color.onSoft,
|
||||||
border = border,
|
border = border,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth().then(origin).then(live),
|
||||||
content = content,
|
content = content,
|
||||||
) else Surface(
|
) else Surface(
|
||||||
shape = MaterialTheme.shapes.large,
|
shape = MaterialTheme.shapes.large,
|
||||||
@@ -627,6 +695,9 @@ internal fun LessonDetails(
|
|||||||
onClose: () -> Unit = {},
|
onClose: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val bell = bells.firstOrNull { it.start == lesson.start }
|
val bell = bells.firstOrNull { it.start == lesson.start }
|
||||||
|
// Hiding closes the sheet, so its "Вернуть" goes to the screen; tasks deleted here, to the sheet.
|
||||||
|
val screenUndo = LocalUndo.current
|
||||||
|
UndoArea(bottom = false) {
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(rememberScrollState())
|
||||||
@@ -668,7 +739,11 @@ internal fun LessonDetails(
|
|||||||
Text(lesson.raw, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(16.dp).fillMaxWidth())
|
Text(lesson.raw, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(16.dp).fillMaxWidth())
|
||||||
}
|
}
|
||||||
if (vm != null) {
|
if (vm != null) {
|
||||||
OutlinedButton(onClick = { vm.setHidden(lesson.hideKey, true); onClose() }) {
|
OutlinedButton(onClick = {
|
||||||
|
vm.setHidden(lesson.hideKey, true)
|
||||||
|
onClose()
|
||||||
|
screenUndo?.offer("Скрыто: «${lesson.subject}»", onUndo = { vm.setHidden(lesson.hideKey, false) })
|
||||||
|
}) {
|
||||||
Icon(Icons.Rounded.VisibilityOff, contentDescription = null, modifier = Modifier.size(18.dp))
|
Icon(Icons.Rounded.VisibilityOff, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
Text("Скрыть такие занятия")
|
Text("Скрыть такие занятия")
|
||||||
@@ -681,6 +756,7 @@ internal fun LessonDetails(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -710,7 +786,7 @@ private fun WordRows(t: Timetable, subgroup: String?, hidden: Set<String>, onOpe
|
|||||||
Heading(dayName(day.day), style = MaterialTheme.typography.headlineSmallEmphasized)
|
Heading(dayName(day.day), style = MaterialTheme.typography.headlineSmallEmphasized)
|
||||||
}
|
}
|
||||||
val lessons = day.lessons.filter { subgroup == null || it.subgroup == null || it.subgroup == subgroup }
|
val lessons = day.lessons.filter { subgroup == null || it.subgroup == null || it.subgroup == subgroup }
|
||||||
.filter { it.hideKey !in hidden }
|
.filter { !it.hiddenIn(hidden) }
|
||||||
val places = if (lessons.isEmpty()) emptyList() else day.places.forSubgroup(subgroup)
|
val places = if (lessons.isEmpty()) emptyList() else day.places.forSubgroup(subgroup)
|
||||||
if (places.isNotEmpty()) item(key = "p${day.day}") { DayPlaces(places) }
|
if (places.isNotEmpty()) item(key = "p${day.day}") { DayPlaces(places) }
|
||||||
items(lessons, key = { "${day.day}-${it.key}" }) { lesson ->
|
items(lessons, key = { "${day.day}-${it.key}" }) { lesson ->
|
||||||
@@ -828,3 +904,31 @@ private val FACULTIES = mapOf(
|
|||||||
)
|
)
|
||||||
|
|
||||||
fun facultyShort(name: String): String? = name.takeIf { it.isNotEmpty() }?.let { FACULTIES[it] ?: it }
|
fun facultyShort(name: String): String? = name.takeIf { it.isNotEmpty() }?.let { FACULTIES[it] ?: it }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens a lesson's details as a page growing out of the card that was tapped (Morph.kt); from
|
||||||
|
* anywhere else (the week grid of a narrow column, a notification) it grows from the middle.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun rememberLessonOpener(vm: MainViewModel): (Lesson, LocalDate?) -> Unit {
|
||||||
|
val morph = LocalMorph.current
|
||||||
|
return remember(morph, vm) {
|
||||||
|
{ lesson, date -> morph?.open(MorphKind.Page) { close -> LessonPage(lesson, date, vm, close) } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The details as a page: a close button over them; full screen on a phone, a panel on a tablet. */
|
||||||
|
@Composable
|
||||||
|
private fun LessonPage(lesson: Lesson, date: LocalDate?, vm: MainViewModel, close: () -> Unit) {
|
||||||
|
val timetable by vm.timetable.collectAsStateWithLifecycle()
|
||||||
|
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||||
|
// The phone's page covers the bars; a tablet's panel sits inside them.
|
||||||
|
val insets = if (maxWidth >= 600.dp) Modifier else Modifier.windowInsetsPadding(WindowInsets.systemBars)
|
||||||
|
Column(Modifier.fillMaxSize().then(insets)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(start = 8.dp, top = 4.dp)) {
|
||||||
|
IconButton(onClick = close) { Icon(Icons.Rounded.Close, contentDescription = "Закрыть") }
|
||||||
|
}
|
||||||
|
LessonDetails(lesson, timetable?.bells.orEmpty(), date, vm, onClose = close)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
|||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.LoadingIndicator
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -243,7 +242,7 @@ fun SearchScreen(vm: MainViewModel, nav: AppNav) {
|
|||||||
) {
|
) {
|
||||||
if (target != null) {
|
if (target != null) {
|
||||||
val d = day
|
val d = day
|
||||||
if (loading && d == null) item { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { LoadingIndicator() } }
|
if (loading && d == null) item { LessonsSkeleton(3) }
|
||||||
if (d != null) {
|
if (d != null) {
|
||||||
if (d.lessons.isEmpty()) item {
|
if (d.lessons.isEmpty()) item {
|
||||||
Box(Modifier.fillParentMaxHeight(0.8f).fillMaxWidth(), contentAlignment = Alignment.Center) {
|
Box(Modifier.fillParentMaxHeight(0.8f).fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
package org.xtimms.psu.schedule.ui
|
package org.xtimms.psu.schedule.ui
|
||||||
|
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.foundation.selection.toggleable
|
import androidx.compose.foundation.selection.toggleable
|
||||||
import androidx.compose.ui.semantics.Role
|
import androidx.compose.ui.semantics.Role
|
||||||
@@ -43,6 +45,8 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import org.xtimms.psu.schedule.BuildConfig
|
import org.xtimms.psu.schedule.BuildConfig
|
||||||
import org.xtimms.psu.schedule.data.ThemeMode
|
import org.xtimms.psu.schedule.data.ThemeMode
|
||||||
|
import org.xtimms.psu.schedule.data.OPTION_PREFIX
|
||||||
|
import org.xtimms.psu.schedule.data.AUDIENCE_PREFIX
|
||||||
import org.xtimms.psu.schedule.notify.Reminders
|
import org.xtimms.psu.schedule.notify.Reminders
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -120,9 +124,12 @@ fun SettingsScreen(vm: MainViewModel, onBack: () -> Unit) {
|
|||||||
SwitchRow("Пометки из Word", "Например, «3-13н.» — даты по ним уже рассчитаны", d.notes) {
|
SwitchRow("Пометки из Word", "Например, «3-13н.» — даты по ним уже рассчитаны", d.notes) {
|
||||||
vm.setDisplay(d.copy(notes = it))
|
vm.setDisplay(d.copy(notes = it))
|
||||||
}
|
}
|
||||||
if (s.hidden.isNotEmpty()) {
|
// The options of a choice the student did not take, and the lessons for foreign
|
||||||
|
// students or the others, are set in "Группы", not here.
|
||||||
|
val hidden = s.hidden.filterNot { it.startsWith(OPTION_PREFIX) || it.startsWith(AUDIENCE_PREFIX) }
|
||||||
|
if (hidden.isNotEmpty()) {
|
||||||
Text("Скрытые занятия", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
Text("Скрытые занятия", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
s.hidden.sorted().forEach { key ->
|
hidden.sorted().forEach { key ->
|
||||||
val (subject, type) = key.split("|", limit = 2).let { it[0] to it.getOrElse(1) { "" } }
|
val (subject, type) = key.split("|", limit = 2).let { it[0] to it.getOrElse(1) { "" } }
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(
|
Text(
|
||||||
@@ -283,7 +290,12 @@ private fun Section(title: String, content: @Composable () -> Unit) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SwitchRow(title: String, text: String, checked: Boolean, onChange: (Boolean) -> Unit) {
|
private fun SwitchRow(title: String, text: String, checked: Boolean, change: (Boolean) -> Unit) {
|
||||||
|
val haptic = LocalHapticFeedback.current
|
||||||
|
val onChange: (Boolean) -> Unit = { on ->
|
||||||
|
haptic.performHapticFeedback(if (on) HapticFeedbackType.ToggleOn else HapticFeedbackType.ToggleOff)
|
||||||
|
change(on)
|
||||||
|
}
|
||||||
// The whole row is the touch target, not only the switch.
|
// The whole row is the touch target, not only the switch.
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
package org.xtimms.psu.schedule.ui
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.LinearEasing
|
||||||
|
import androidx.compose.animation.core.animateFloat
|
||||||
|
import androidx.compose.animation.core.infiniteRepeatable
|
||||||
|
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.drawBehind
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Shape
|
||||||
|
import androidx.compose.ui.semantics.LiveRegionMode
|
||||||
|
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.liveRegion
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import org.xtimms.psu.schedule.ui.theme.card
|
||||||
|
|
||||||
|
// Placeholders in the shape of what is loading: the first timetable of a group, a teacher's day,
|
||||||
|
// the catalog. The screen keeps its layout instead of a lone spinner in the middle.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A soft light sweeping across a placeholder, once every 1.2 s. With animations turned off in
|
||||||
|
* the system it stays a plain tone.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun Modifier.shimmer(): Modifier {
|
||||||
|
val ink = MaterialTheme.colorScheme.onSurface
|
||||||
|
val base = ink.copy(alpha = 0.08f)
|
||||||
|
val light = ink.copy(alpha = 0.15f)
|
||||||
|
if (rememberReducedMotion()) return background(base)
|
||||||
|
val sweep = rememberInfiniteTransition(label = "shimmer")
|
||||||
|
val x by sweep.animateFloat(-1f, 2f, infiniteRepeatable(tween(1200, easing = LinearEasing)), label = "x")
|
||||||
|
return drawBehind {
|
||||||
|
val w = size.width.coerceAtLeast(1f)
|
||||||
|
drawRect(Brush.linearGradient(listOf(base, light, base), start = Offset(w * (x - 0.5f), 0f), end = Offset(w * (x + 0.5f), size.height)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One placeholder bar or shape. */
|
||||||
|
@Composable
|
||||||
|
private fun Bone(modifier: Modifier, shape: Shape = MaterialTheme.shapes.small) {
|
||||||
|
Box(modifier.clip(shape).shimmer())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Screen readers hear "Загрузка" once, not every bar. */
|
||||||
|
private fun Modifier.loading(): Modifier = clearAndSetSemantics {
|
||||||
|
contentDescription = "Загрузка"
|
||||||
|
liveRegion = LiveRegionMode.Polite
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A lesson card: subject, time, room and teacher. */
|
||||||
|
@Composable
|
||||||
|
fun LessonCardSkeleton(modifier: Modifier = Modifier) {
|
||||||
|
Surface(shape = MaterialTheme.shapes.large, color = MaterialTheme.colorScheme.surfaceContainerHigh, modifier = modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(horizontal = 20.dp, vertical = 16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
|
Bone(Modifier.fillMaxWidth(0.72f).height(20.dp))
|
||||||
|
Bone(Modifier.fillMaxWidth(0.4f).height(16.dp))
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
|
Bone(Modifier.width(72.dp).height(14.dp))
|
||||||
|
Bone(Modifier.width(112.dp).height(14.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A few lesson cards, for a day that is loading. */
|
||||||
|
@Composable
|
||||||
|
fun LessonsSkeleton(count: Int = 3, modifier: Modifier = Modifier) {
|
||||||
|
Column(modifier.loading(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
repeat(count) { LessonCardSkeleton() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The schedule: the week's day strip, then the day's cards. */
|
||||||
|
@Composable
|
||||||
|
fun ScheduleSkeleton() {
|
||||||
|
Column(Modifier.fillMaxSize().loading()) {
|
||||||
|
Row(Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 8.dp)) {
|
||||||
|
repeat(7) {
|
||||||
|
Column(Modifier.weight(1f), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Bone(Modifier.width(18.dp).height(12.dp))
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Bone(Modifier.size(36.dp), CircleShape)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ContentColumn {
|
||||||
|
LessonsSkeleton(4, Modifier.padding(16.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The overview: the week card, "Сегодня" and the next lessons. */
|
||||||
|
@Composable
|
||||||
|
fun OverviewSkeleton() {
|
||||||
|
ContentColumn {
|
||||||
|
Column(
|
||||||
|
Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 8.dp).loading(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
SkeletonCard {
|
||||||
|
Bone(Modifier.width(120.dp).height(20.dp))
|
||||||
|
Bone(Modifier.fillMaxWidth(0.7f).height(26.dp))
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
repeat(6) { Bone(Modifier.weight(1f).height(52.dp), MaterialTheme.shapes.medium) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Bone(Modifier.width(140.dp).height(28.dp).padding(top = 4.dp))
|
||||||
|
SkeletonCard {
|
||||||
|
Bone(Modifier.width(180.dp).height(20.dp))
|
||||||
|
repeat(3) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Bone(Modifier.size(12.dp), CircleShape)
|
||||||
|
Spacer(Modifier.width(16.dp))
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
Bone(Modifier.widthIn(max = 260.dp).fillMaxWidth(0.8f).height(18.dp))
|
||||||
|
Bone(Modifier.width(120.dp).height(14.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subjects: a card per subject with its badge, name and next lesson. */
|
||||||
|
@Composable
|
||||||
|
fun SubjectsSkeleton() {
|
||||||
|
ContentColumn {
|
||||||
|
Column(
|
||||||
|
Modifier.fillMaxSize().padding(16.dp).loading(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
repeat(5) {
|
||||||
|
SkeletonCard {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Bone(Modifier.size(48.dp), CircleShape)
|
||||||
|
Spacer(Modifier.width(16.dp))
|
||||||
|
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Bone(Modifier.fillMaxWidth(0.75f).height(18.dp))
|
||||||
|
Bone(Modifier.fillMaxWidth(0.5f).height(14.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rows of a list with an avatar: the groups of the catalog. */
|
||||||
|
@Composable
|
||||||
|
fun RowsSkeleton(count: Int = 6, avatar: Dp = 40.dp) {
|
||||||
|
Column(Modifier.fillMaxWidth().loading(), verticalArrangement = Arrangement.spacedBy(18.dp)) {
|
||||||
|
repeat(count) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(horizontal = 4.dp)) {
|
||||||
|
Bone(Modifier.size(avatar), CircleShape)
|
||||||
|
Spacer(Modifier.width(16.dp))
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
Bone(Modifier.width(120.dp).height(16.dp))
|
||||||
|
Bone(Modifier.width(180.dp).height(13.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SkeletonCard(content: @Composable () -> Unit) {
|
||||||
|
Surface(shape = MaterialTheme.shapes.extraLarge, color = MaterialTheme.colorScheme.card, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { content() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,7 +41,6 @@ import androidx.compose.material.icons.rounded.Person
|
|||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.LoadingIndicator
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.ModalBottomSheet
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
@@ -128,7 +127,7 @@ fun SubjectsScreen(vm: MainViewModel, nav: AppNav) {
|
|||||||
)
|
)
|
||||||
val t = timetable
|
val t = timetable
|
||||||
when {
|
when {
|
||||||
t == null && status.loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { LoadingIndicator() }
|
t == null && status.loading -> SubjectsSkeleton()
|
||||||
t == null -> CenteredEmpty {
|
t == null -> CenteredEmpty {
|
||||||
Empty("Предметов пока нет", "Выберите группу — предметы появятся из её расписания.", icon = Icons.AutoMirrored.Rounded.MenuBook, action = "Выбрать группу" to nav.openGroups)
|
Empty("Предметов пока нет", "Выберите группу — предметы появятся из её расписания.", icon = Icons.AutoMirrored.Rounded.MenuBook, action = "Выбрать группу" to nav.openGroups)
|
||||||
}
|
}
|
||||||
@@ -328,6 +327,8 @@ private fun Fact(icon: ImageVector, text: String, strong: Boolean = false) {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun SubjectDetails(s: SubjectSummary, vm: MainViewModel) {
|
private fun SubjectDetails(s: SubjectSummary, vm: MainViewModel) {
|
||||||
val color = subjectColor(s.name)
|
val color = subjectColor(s.name)
|
||||||
|
// A task deleted in the sheet offers "Вернуть" in the sheet itself.
|
||||||
|
UndoArea(bottom = false) {
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(rememberScrollState())
|
||||||
@@ -372,6 +373,7 @@ private fun SubjectDetails(s: SubjectSummary, vm: MainViewModel) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -389,6 +391,7 @@ private fun DetailLine(icon: ImageVector, label: String, value: String) {
|
|||||||
/** Every task and note of the subject, open ones first, with a quick add for the next lesson. */
|
/** Every task and note of the subject, open ones first, with a quick add for the next lesson. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun SubjectNotes(s: SubjectSummary, vm: MainViewModel) {
|
private fun SubjectNotes(s: SubjectSummary, vm: MainViewModel) {
|
||||||
|
val undo = LocalUndo.current
|
||||||
val all by vm.notes.collectAsStateWithLifecycle()
|
val all by vm.notes.collectAsStateWithLifecycle()
|
||||||
val group = vm.currentGroupKey() ?: return
|
val group = vm.currentGroupKey() ?: return
|
||||||
val notes = all.filter { subjectKey(it.subject) == subjectKey(s.name) }
|
val notes = all.filter { subjectKey(it.subject) == subjectKey(s.name) }
|
||||||
@@ -414,7 +417,7 @@ private fun SubjectNotes(s: SubjectSummary, vm: MainViewModel) {
|
|||||||
editing = editing,
|
editing = editing,
|
||||||
groupKey = editing?.group ?: group,
|
groupKey = editing?.group ?: group,
|
||||||
onSave = { vm.saveNote(it); adding = false; editing = null },
|
onSave = { vm.saveNote(it); adding = false; editing = null },
|
||||||
onDelete = { vm.deleteNote(it.id); editing = null },
|
onDelete = { vm.deleteNote(it.id, undo); editing = null },
|
||||||
onDismiss = { adding = false; editing = null },
|
onDismiss = { adding = false; editing = null },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ private fun StandLesson(slot: Slot, clock: LocalTime, isToday: Boolean, onOpen:
|
|||||||
shape = MaterialTheme.shapes.extraLarge,
|
shape = MaterialTheme.shapes.extraLarge,
|
||||||
color = color.soft,
|
color = color.soft,
|
||||||
contentColor = color.onSoft,
|
contentColor = color.onSoft,
|
||||||
modifier = modifier,
|
modifier = modifier.morphOrigin("stand|${slot.date}|${lesson.key}|${lesson.start}", color.soft, 28.dp),
|
||||||
) {
|
) {
|
||||||
Column(Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
@@ -147,7 +147,7 @@ private fun StandLesson(slot: Slot, clock: LocalTime, isToday: Boolean, onOpen:
|
|||||||
color = if (running) color.container else MaterialTheme.colorScheme.surface,
|
color = if (running) color.container else MaterialTheme.colorScheme.surface,
|
||||||
contentColor = if (running) color.content else MaterialTheme.colorScheme.onSurface,
|
contentColor = if (running) color.content else MaterialTheme.colorScheme.onSurface,
|
||||||
) {
|
) {
|
||||||
Text(
|
RollingText(
|
||||||
if (running) "Идёт · ещё ${minutesLabel(minutes)}" else "Через ${minutesLabel(minutes)}",
|
if (running) "Идёт · ещё ${minutesLabel(minutes)}" else "Через ${minutesLabel(minutes)}",
|
||||||
style = MaterialTheme.typography.labelLarge,
|
style = MaterialTheme.typography.labelLarge,
|
||||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package org.xtimms.psu.schedule.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.material3.SnackbarDuration
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.SnackbarResult
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Задание удалено · Вернуть": actions that are easy to regret are done at once and offered back
|
||||||
|
* in a snackbar instead of asking first. [offer] shows it in the nearest host — the screen's, or
|
||||||
|
* a bottom sheet's own, since a sheet covers the screen's.
|
||||||
|
*/
|
||||||
|
class UndoHost(val state: SnackbarHostState, private val scope: CoroutineScope) {
|
||||||
|
/** [onGone] runs when the chance to undo has passed: the snackbar timed out or was replaced. */
|
||||||
|
fun offer(message: String, onUndo: () -> Unit, onGone: () -> Unit = {}) {
|
||||||
|
scope.launch {
|
||||||
|
// A new action replaces the previous offer, which then cannot be undone any more.
|
||||||
|
state.currentSnackbarData?.dismiss()
|
||||||
|
var undone = false
|
||||||
|
try {
|
||||||
|
undone = state.showSnackbar(message, actionLabel = "Вернуть", duration = SnackbarDuration.Long) ==
|
||||||
|
SnackbarResult.ActionPerformed
|
||||||
|
if (undone) onUndo()
|
||||||
|
} finally {
|
||||||
|
if (!undone) onGone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val LocalUndo = staticCompositionLocalOf<UndoHost?> { null }
|
||||||
|
|
||||||
|
/** A host for [content] and its snackbar at the bottom: the app's screens, or a sheet. */
|
||||||
|
@Composable
|
||||||
|
fun UndoArea(modifier: Modifier = Modifier, bottom: Boolean = true, content: @Composable BoxScope.() -> Unit) {
|
||||||
|
val state = remember { SnackbarHostState() }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val host = remember(state, scope) { UndoHost(state, scope) }
|
||||||
|
Box(modifier) {
|
||||||
|
CompositionLocalProvider(LocalUndo provides host) { content() }
|
||||||
|
SnackbarHost(
|
||||||
|
state,
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.then(if (bottom) Modifier.navigationBarsPadding() else Modifier)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||||
|
.widthIn(max = 560.dp)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
package org.xtimms.psu.schedule.ui
|
package org.xtimms.psu.schedule.ui
|
||||||
|
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.ui.text.intl.LocaleList
|
import androidx.compose.ui.text.intl.LocaleList
|
||||||
import androidx.compose.ui.text.style.Hyphens
|
import androidx.compose.ui.text.style.Hyphens
|
||||||
import androidx.compose.ui.text.style.LineBreak
|
import androidx.compose.ui.text.style.LineBreak
|
||||||
@@ -165,7 +167,8 @@ fun WeekGrid(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (d == today() && clock.hour in first until last) {
|
if (d == today() && clock.hour in first until last) {
|
||||||
val y = top(clock)
|
// The "now" line slides down with the clock instead of hopping every half minute.
|
||||||
|
val y by androidx.compose.animation.core.animateDpAsState(top(clock), tween(1000), label = "now")
|
||||||
Box(Modifier.offset(y = y - 1.dp).fillMaxWidth().height(2.dp).background(MaterialTheme.colorScheme.primary))
|
Box(Modifier.offset(y = y - 1.dp).fillMaxWidth().height(2.dp).background(MaterialTheme.colorScheme.primary))
|
||||||
Box(Modifier.offset(x = (-5).dp, y = y - 5.dp).size(10.dp).background(MaterialTheme.colorScheme.primary, CircleShape))
|
Box(Modifier.offset(x = (-5).dp, y = y - 5.dp).size(10.dp).background(MaterialTheme.colorScheme.primary, CircleShape))
|
||||||
}
|
}
|
||||||
@@ -179,6 +182,7 @@ fun WeekGrid(
|
|||||||
private fun LessonBlock(lesson: Lesson, slot: Slot, wide: Boolean, notes: Int, onClick: () -> Unit, modifier: Modifier) {
|
private fun LessonBlock(lesson: Lesson, slot: Slot, wide: Boolean, notes: Int, onClick: () -> Unit, modifier: Modifier) {
|
||||||
val color = subjectColor(lesson.subject)
|
val color = subjectColor(lesson.subject)
|
||||||
val session = lesson.session
|
val session = lesson.session
|
||||||
|
val origin = Modifier.morphOrigin("grid|${slot.date}|${lesson.key}|${lesson.start}", color.container, 8.dp)
|
||||||
val rooms = lesson.rooms.joinToString(", ").takeIf { LocalDisplay.current.rooms }.orEmpty()
|
val rooms = lesson.rooms.joinToString(", ").takeIf { LocalDisplay.current.rooms }.orEmpty()
|
||||||
// A narrow column keeps the room whole: the day's town is under its date.
|
// A narrow column keeps the room whole: the day's town is under its date.
|
||||||
val room = when {
|
val room = when {
|
||||||
@@ -192,7 +196,7 @@ private fun LessonBlock(lesson: Lesson, slot: Slot, wide: Boolean, notes: Int, o
|
|||||||
color = color.container,
|
color = color.container,
|
||||||
contentColor = color.content,
|
contentColor = color.content,
|
||||||
border = session?.let { androidx.compose.foundation.BorderStroke(2.dp, it.outline()) },
|
border = session?.let { androidx.compose.foundation.BorderStroke(2.dp, it.outline()) },
|
||||||
modifier = modifier.semantics {
|
modifier = modifier.then(origin).semantics {
|
||||||
contentDescription = "${lesson.subject}, ${slot.start}–${slot.end}" +
|
contentDescription = "${lesson.subject}, ${slot.start}–${slot.end}" +
|
||||||
(if (room.isNotEmpty()) ", ауд. $room" else "") + (lesson.type.takeIf { it.isNotEmpty() }?.let { ", $it" } ?: "") +
|
(if (room.isNotEmpty()) ", ауд. $room" else "") + (lesson.type.takeIf { it.isNotEmpty() }?.let { ", $it" } ?: "") +
|
||||||
(if (notes > 0) ", заданий и заметок: $notes" else "")
|
(if (notes > 0) ", заданий и заметок: $notes" else "")
|
||||||
|
|||||||
@@ -0,0 +1,246 @@
|
|||||||
|
package org.xtimms.psu.schedule.widget
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.glance.ColorFilter
|
||||||
|
import androidx.glance.GlanceId
|
||||||
|
import androidx.glance.GlanceModifier
|
||||||
|
import androidx.glance.GlanceTheme
|
||||||
|
import androidx.glance.Image
|
||||||
|
import androidx.glance.ImageProvider
|
||||||
|
import androidx.glance.LocalSize
|
||||||
|
import androidx.glance.appwidget.action.actionStartActivity
|
||||||
|
import androidx.glance.action.clickable
|
||||||
|
import androidx.glance.appwidget.GlanceAppWidget
|
||||||
|
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||||
|
import androidx.glance.appwidget.SizeMode
|
||||||
|
import androidx.glance.appwidget.provideContent
|
||||||
|
import androidx.glance.layout.Alignment
|
||||||
|
import androidx.glance.layout.Column
|
||||||
|
import androidx.glance.layout.Row
|
||||||
|
import androidx.glance.layout.Spacer
|
||||||
|
import androidx.glance.layout.fillMaxHeight
|
||||||
|
import androidx.glance.layout.fillMaxSize
|
||||||
|
import androidx.glance.layout.fillMaxWidth
|
||||||
|
import androidx.glance.layout.height
|
||||||
|
import androidx.glance.layout.padding
|
||||||
|
import androidx.glance.layout.size
|
||||||
|
import androidx.glance.layout.width
|
||||||
|
import androidx.glance.text.FontWeight
|
||||||
|
import androidx.glance.text.Text
|
||||||
|
import androidx.glance.text.TextAlign
|
||||||
|
import androidx.glance.text.TextStyle
|
||||||
|
import org.xtimms.psu.schedule.Launch
|
||||||
|
import org.xtimms.psu.schedule.R
|
||||||
|
import org.xtimms.psu.schedule.app
|
||||||
|
import org.xtimms.psu.schedule.data.Week
|
||||||
|
import org.xtimms.psu.schedule.ui.theme.WeekColors
|
||||||
|
import androidx.glance.color.ColorProvider
|
||||||
|
import org.xtimms.psu.schedule.data.longLabel
|
||||||
|
import org.xtimms.psu.schedule.data.monday
|
||||||
|
import org.xtimms.psu.schedule.data.plural
|
||||||
|
import org.xtimms.psu.schedule.data.shortWeekday
|
||||||
|
import org.xtimms.psu.schedule.data.today
|
||||||
|
import org.xtimms.psu.schedule.ui.abbreviate
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
/** A day of the week as the widget shows it. */
|
||||||
|
private data class WeekDay(
|
||||||
|
val date: LocalDate,
|
||||||
|
/** Abbreviations of the day's lessons, one per start time: "ФГА", "ЯПОА / ПУПР". */
|
||||||
|
val lessons: List<String>,
|
||||||
|
/** "Полоцк" when the day is in another town. */
|
||||||
|
val place: String?,
|
||||||
|
val moved: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class WeekState(
|
||||||
|
val group: String?,
|
||||||
|
val week: Week?,
|
||||||
|
val monday: LocalDate,
|
||||||
|
val days: List<WeekDay>,
|
||||||
|
val dynamic: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
private suspend fun state(context: Context): WeekState? {
|
||||||
|
val app = context.app
|
||||||
|
val t = app.repository.timetable.value ?: return null
|
||||||
|
val s = app.settings.now()
|
||||||
|
// On Sunday the coming week is the useful one, as in the overview's week pane.
|
||||||
|
val monday = (if (today().dayOfWeek.value == 7) today().plusDays(1) else today()).monday()
|
||||||
|
val all = (0..6L).map { monday.plusDays(it) }.map { d ->
|
||||||
|
val lessons = app.repository.lessonsOn(t, d, s.subgroup, s.hidden)
|
||||||
|
WeekDay(
|
||||||
|
date = d,
|
||||||
|
lessons = lessons.groupBy { it.start }.toSortedMap().values.map { slot -> slot.joinToString(" / ") { abbreviate(it.subject) } },
|
||||||
|
place = app.repository.placesOn(t, d, s.subgroup, s.hidden).firstOrNull()?.place,
|
||||||
|
moved = lessons.any { it.roomMoved },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return WeekState(
|
||||||
|
group = t.group,
|
||||||
|
week = t.days.firstOrNull { it.date == monday.toString() }?.week,
|
||||||
|
monday = monday,
|
||||||
|
// Sunday only takes a column when something happens on it.
|
||||||
|
days = all.filter { it.date.dayOfWeek.value < 7 || it.lessons.isNotEmpty() },
|
||||||
|
dynamic = s.dynamicColor,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The week at a glance (4×2 and larger): each day with its number of lessons, a city mark on
|
||||||
|
* days in another town and a swap mark on days with a room move; taller, the lessons too. A day
|
||||||
|
* opens in the app.
|
||||||
|
*/
|
||||||
|
class WeekWidget : GlanceAppWidget() {
|
||||||
|
override val sizeMode = SizeMode.Exact
|
||||||
|
|
||||||
|
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||||
|
val data = state(context)
|
||||||
|
val dynamic = data?.dynamic ?: context.app.settings.now().dynamicColor
|
||||||
|
provideContent { WidgetTheme(dynamic) { Content(context, data) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Content(context: Context, data: WeekState?) {
|
||||||
|
Column(
|
||||||
|
modifier = GlanceModifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.roundedBackground(GlanceTheme.colors.widgetBackground, 28.dp)
|
||||||
|
.padding(12.dp),
|
||||||
|
) {
|
||||||
|
if (data == null) {
|
||||||
|
Text(
|
||||||
|
"Откройте приложение и выберите группу",
|
||||||
|
style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 15.sp),
|
||||||
|
modifier = GlanceModifier.padding(4.dp).clickable(actionStartActivity(Launch.intent(context, "today"))),
|
||||||
|
)
|
||||||
|
return@Column
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = GlanceModifier.fillMaxWidth().padding(start = 4.dp, end = 4.dp, bottom = 8.dp)
|
||||||
|
.clickable(actionStartActivity(Launch.intent(context, "week"))),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
data.week?.let { "Неделя ${it.n}" } ?: "Неделя",
|
||||||
|
style = TextStyle(color = GlanceTheme.colors.primary, fontWeight = FontWeight.Bold, fontSize = 15.sp),
|
||||||
|
)
|
||||||
|
data.week?.let { w ->
|
||||||
|
Spacer(GlanceModifier.width(8.dp))
|
||||||
|
// The university's week colours, as on the app's week mark.
|
||||||
|
Text(
|
||||||
|
if (w.white) "белая" else "зелёная",
|
||||||
|
style = TextStyle(
|
||||||
|
color = ColorProvider(day = WeekColors.content(w.white, false), night = WeekColors.content(w.white, true)),
|
||||||
|
fontSize = 11.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
),
|
||||||
|
modifier = GlanceModifier
|
||||||
|
.roundedBackground(
|
||||||
|
if (w.white) GlanceTheme.colors.surfaceVariant
|
||||||
|
else ColorProvider(day = WeekColors.container(false, false), night = WeekColors.container(false, true)),
|
||||||
|
8.dp,
|
||||||
|
)
|
||||||
|
.padding(horizontal = 6.dp, vertical = 1.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(GlanceModifier.defaultWeight())
|
||||||
|
Text(data.group.orEmpty(), maxLines = 1, style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp))
|
||||||
|
}
|
||||||
|
// Header about 40dp, padding 24dp; a lesson line about 14dp below the day's head.
|
||||||
|
val height = LocalSize.current.height
|
||||||
|
val lines = ((height - 40.dp - 24.dp - 62.dp) / 14.dp).toInt().coerceIn(0, 6)
|
||||||
|
val summary = summary(data.days)
|
||||||
|
val roomForSummary = summary != null && height >= 150.dp
|
||||||
|
// Glance keeps at most ten children in a row: the gaps are the tiles' padding, not spacers.
|
||||||
|
Row(modifier = GlanceModifier.fillMaxWidth().defaultWeight()) {
|
||||||
|
data.days.forEachIndexed { index, day ->
|
||||||
|
DayTile(
|
||||||
|
context, day, lines - if (roomForSummary) 1 else 0,
|
||||||
|
GlanceModifier.defaultWeight().fillMaxHeight()
|
||||||
|
.padding(start = if (index > 0) 2.dp else 0.dp, end = if (index < data.days.lastIndex) 2.dp else 0.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (roomForSummary) Text(
|
||||||
|
summary!!,
|
||||||
|
maxLines = 1,
|
||||||
|
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 11.sp),
|
||||||
|
modifier = GlanceModifier.padding(start = 4.dp, top = 6.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DayTile(context: Context, day: WeekDay, lines: Int, modifier: GlanceModifier) {
|
||||||
|
val isToday = day.date == today()
|
||||||
|
val past = day.date < today()
|
||||||
|
val count = day.lessons.size
|
||||||
|
val (fill, ink) = when {
|
||||||
|
isToday -> GlanceTheme.colors.primary to GlanceTheme.colors.onPrimary
|
||||||
|
count > 0 -> GlanceTheme.colors.secondaryContainer to GlanceTheme.colors.onSecondaryContainer
|
||||||
|
else -> GlanceTheme.colors.surfaceVariant to GlanceTheme.colors.onSurfaceVariant
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = modifier
|
||||||
|
.roundedBackground(fill, 16.dp)
|
||||||
|
.padding(vertical = 6.dp, horizontal = 2.dp)
|
||||||
|
.clickable(actionStartActivity(Launch.intent(context, "day", day.date))),
|
||||||
|
) {
|
||||||
|
Text(day.date.shortWeekday(), style = TextStyle(color = ink, fontSize = 11.sp, textAlign = TextAlign.Center))
|
||||||
|
Text(
|
||||||
|
"${day.date.dayOfMonth}",
|
||||||
|
style = TextStyle(color = ink, fontSize = 17.sp, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
if (count == 0) "—" else "$count ${plural(count, "пара", "пары", "пар")}",
|
||||||
|
maxLines = 1,
|
||||||
|
style = TextStyle(
|
||||||
|
color = ink,
|
||||||
|
fontSize = 10.sp,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
fontWeight = if (past) FontWeight.Normal else FontWeight.Medium,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (day.place != null || day.moved) Row(modifier = GlanceModifier.padding(top = 2.dp)) {
|
||||||
|
if (day.place != null) Image(
|
||||||
|
ImageProvider(R.drawable.ic_widget_city),
|
||||||
|
contentDescription = "Занятия: ${day.place}",
|
||||||
|
colorFilter = ColorFilter.tint(if (isToday) ink else GlanceTheme.colors.tertiary),
|
||||||
|
modifier = GlanceModifier.size(14.dp),
|
||||||
|
)
|
||||||
|
if (day.moved) Image(
|
||||||
|
ImageProvider(R.drawable.ic_widget_move),
|
||||||
|
contentDescription = "Перенос аудитории",
|
||||||
|
colorFilter = ColorFilter.tint(if (isToday) ink else GlanceTheme.colors.error),
|
||||||
|
modifier = GlanceModifier.size(14.dp).padding(start = if (day.place != null) 2.dp else 0.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Taller: the lessons themselves, as the week grid abbreviates them.
|
||||||
|
if (lines > 0) {
|
||||||
|
Spacer(GlanceModifier.height(2.dp))
|
||||||
|
day.lessons.take(lines).forEach {
|
||||||
|
Text(it, maxLines = 1, style = TextStyle(color = ink, fontSize = 10.sp, textAlign = TextAlign.Center))
|
||||||
|
}
|
||||||
|
if (day.lessons.size > lines) Text("…", style = TextStyle(color = ink, fontSize = 10.sp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Полоцк: вт, пт · перенос: пн, чт", or null for an ordinary week. */
|
||||||
|
private fun summary(days: List<WeekDay>): String? {
|
||||||
|
val away = days.filter { it.place != null }.groupBy { it.place!! }
|
||||||
|
.map { (place, list) -> "$place: " + list.joinToString(", ") { it.date.shortWeekday().lowercase() } }
|
||||||
|
val moved = days.filter { it.moved }.takeIf { it.isNotEmpty() }
|
||||||
|
?.let { list -> "перенос: " + list.joinToString(", ") { it.date.shortWeekday().lowercase() } }
|
||||||
|
return (away + listOfNotNull(moved)).joinToString(" · ").ifEmpty { null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class WeekWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||||
|
override val glanceAppWidget: GlanceAppWidget = WeekWidget()
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import org.xtimms.psu.schedule.notify.DayAlerts
|
|||||||
import org.xtimms.psu.schedule.notify.LiveLesson
|
import org.xtimms.psu.schedule.notify.LiveLesson
|
||||||
import org.xtimms.psu.schedule.notify.Reminders
|
import org.xtimms.psu.schedule.notify.Reminders
|
||||||
import org.xtimms.psu.schedule.widget.TodayWidget
|
import org.xtimms.psu.schedule.widget.TodayWidget
|
||||||
|
import org.xtimms.psu.schedule.widget.WeekWidget
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -61,6 +62,7 @@ class SyncWorker(context: Context, params: WorkerParameters) : CoroutineWorker(c
|
|||||||
LiveLesson.reschedule(context, t, s, app.repository)
|
LiveLesson.reschedule(context, t, s, app.repository)
|
||||||
TodayWidget().updateAll(context)
|
TodayWidget().updateAll(context)
|
||||||
NextLessonWidget().updateAll(context)
|
NextLessonWidget().updateAll(context)
|
||||||
|
WeekWidget().updateAll(context)
|
||||||
LessonTileService.refresh(context)
|
LessonTileService.refresh(context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,6 +73,7 @@ class WidgetTick(context: Context, params: WorkerParameters) : CoroutineWorker(c
|
|||||||
override suspend fun doWork(): Result {
|
override suspend fun doWork(): Result {
|
||||||
TodayWidget().updateAll(applicationContext)
|
TodayWidget().updateAll(applicationContext)
|
||||||
NextLessonWidget().updateAll(applicationContext)
|
NextLessonWidget().updateAll(applicationContext)
|
||||||
|
WeekWidget().updateAll(applicationContext)
|
||||||
LessonTileService.refresh(applicationContext)
|
LessonTileService.refresh(applicationContext)
|
||||||
return Result.success()
|
return Result.success()
|
||||||
}
|
}
|
||||||
|
|||||||
16
android/app/src/main/res/animator/splash_calendar.xml
Normal file
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
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
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
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
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
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>
|
||||||
11
android/app/src/main/res/drawable/ic_widget_city.xml
Normal file
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
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
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
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" />
|
||||||
@@ -5,4 +5,12 @@
|
|||||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<!-- The launch screen (core-splashscreen): the app surface and the animated icon, then the app. -->
|
||||||
|
<style name="Theme.PsuSchedule.Starting" parent="Theme.SplashScreen">
|
||||||
|
<item name="windowSplashScreenBackground">@color/surface</item>
|
||||||
|
<item name="windowSplashScreenAnimatedIcon">@drawable/splash_icon_animated</item>
|
||||||
|
<item name="windowSplashScreenAnimationDuration">750</item>
|
||||||
|
<item name="postSplashScreenTheme">@style/Theme.PsuSchedule</item>
|
||||||
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -17,5 +17,7 @@
|
|||||||
<string name="shortcut_task_long">Новое задание</string>
|
<string name="shortcut_task_long">Новое задание</string>
|
||||||
<string name="next_widget_name">Следующая пара</string>
|
<string name="next_widget_name">Следующая пара</string>
|
||||||
<string name="next_widget_description">Когда следующая пара, что это и в какой аудитории</string>
|
<string name="next_widget_description">Когда следующая пара, что это и в какой аудитории</string>
|
||||||
|
<string name="week_widget_name">Неделя</string>
|
||||||
|
<string name="week_widget_description">Пары каждого дня недели, дни в другом городе и переносы аудиторий</string>
|
||||||
<string name="tile_name">Пара</string>
|
<string name="tile_name">Пара</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -6,4 +6,12 @@
|
|||||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<!-- The launch screen (core-splashscreen): the app surface and the animated icon, then the app. -->
|
||||||
|
<style name="Theme.PsuSchedule.Starting" parent="Theme.SplashScreen">
|
||||||
|
<item name="windowSplashScreenBackground">@color/surface</item>
|
||||||
|
<item name="windowSplashScreenAnimatedIcon">@drawable/splash_icon_animated</item>
|
||||||
|
<item name="windowSplashScreenAnimationDuration">750</item>
|
||||||
|
<item name="postSplashScreenTheme">@style/Theme.PsuSchedule</item>
|
||||||
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
5
android/app/src/main/res/xml/photo_paths.xml
Normal file
5
android/app/src/main/res/xml/photo_paths.xml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Where the camera app may write a photo for a task: the cache, copied and shrunk afterwards. -->
|
||||||
|
<paths>
|
||||||
|
<cache-path name="camera" path="camera/" />
|
||||||
|
</paths>
|
||||||
11
android/app/src/main/res/xml/week_widget_info.xml
Normal file
11
android/app/src/main/res/xml/week_widget_info.xml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:description="@string/week_widget_description"
|
||||||
|
android:initialLayout="@layout/glance_default_loading_layout"
|
||||||
|
android:minWidth="250dp"
|
||||||
|
android:minHeight="110dp"
|
||||||
|
android:targetCellWidth="4"
|
||||||
|
android:targetCellHeight="2"
|
||||||
|
android:resizeMode="horizontal|vertical"
|
||||||
|
android:updatePeriodMillis="1800000"
|
||||||
|
android:widgetCategory="home_screen" />
|
||||||
@@ -5,6 +5,7 @@ composeBom = "2026.09.01"
|
|||||||
activity = "1.13.0"
|
activity = "1.13.0"
|
||||||
lifecycle = "2.11.0"
|
lifecycle = "2.11.0"
|
||||||
coreKtx = "1.19.1"
|
coreKtx = "1.19.1"
|
||||||
|
splashscreen = "1.2.0"
|
||||||
coroutines = "1.11.0"
|
coroutines = "1.11.0"
|
||||||
serialization = "1.11.0"
|
serialization = "1.11.0"
|
||||||
okhttp = "4.12.0"
|
okhttp = "4.12.0"
|
||||||
@@ -27,6 +28,8 @@ activity-compose = { module = "androidx.activity:activity-compose", version.ref
|
|||||||
lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
|
lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
|
||||||
lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" }
|
lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" }
|
||||||
core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" }
|
core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" }
|
||||||
|
# The launch screen with the animated icon on every Android version (Android 12+ draws its own).
|
||||||
|
splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "splashscreen" }
|
||||||
coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
|
coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
|
||||||
serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" }
|
serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" }
|
||||||
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
|
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
|
||||||
|
|||||||
@@ -15,7 +15,11 @@ export const lessonLine = (l) =>
|
|||||||
// The town only when the cell names one or the day's note puts the lesson there.
|
// The town only when the cell names one or the day's note puts the lesson there.
|
||||||
[l.room, l.place].filter(Boolean).join(", "),
|
[l.room, l.place].filter(Boolean).join(", "),
|
||||||
l.teacher,
|
l.teacher,
|
||||||
[...l.groups, ...l.subgroups.map((s) => `${s.group}/${s.label.replace("Подгруппа ", "п")}`)].join(","),
|
[...l.groups, ...l.subgroups.map((s) => `${s.group}/${s.label.replace("Подгруппа ", "п")}`)].join(",") +
|
||||||
|
// "Немецкий язык" or "Французский язык": a part of the group takes one of them.
|
||||||
|
(l.option ? " · на выбор" : "") +
|
||||||
|
// Russian for foreign students while the others have physical education.
|
||||||
|
(l.audience === "foreign" ? " · иностранцам" : l.audience === "local" ? " · кроме иностранцев" : ""),
|
||||||
]
|
]
|
||||||
.map((part) => String(part ?? "").replace(/\s+/g, " ").trim())
|
.map((part) => String(part ?? "").replace(/\s+/g, " ").trim())
|
||||||
.join(" | ");
|
.join(" | ");
|
||||||
|
|||||||
@@ -80,7 +80,9 @@ app.use("/api", (_req, res, next) => {
|
|||||||
// 12 — a JSON log line per request;
|
// 12 — a JSON log line per request;
|
||||||
// 13 — the dashboard at /status (password), /api/health only says alive, ready and versions;
|
// 13 — the dashboard at /status (password), /api/health only says alive, ready and versions;
|
||||||
// 14 — a lesson's `place` and a day's `places` ("Занятия в Полоцке") in /api/timetable.
|
// 14 — a lesson's `place` and a day's `places` ("Занятия в Полоцке") in /api/timetable.
|
||||||
const API_VERSION = 14;
|
// 15 — a lesson's `option` and the group's `choices` (German or French for parts of a group),
|
||||||
|
// a lesson's `audience` (foreign students or the others) and the group's `audience` flag.
|
||||||
|
const API_VERSION = 15;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What the server still waits for before it answers from memory: the catalog, the week table
|
* What the server still waits for before it answers from memory: the catalog, the week table
|
||||||
|
|||||||
134
server/parser.js
134
server/parser.js
@@ -10,7 +10,7 @@ import { parseWeekRanges, parseTextDates, DATE_LIST, weekday } from "../shared/w
|
|||||||
import { roomMoves, onlyPlace, weekPlan, partsByWeek } from "./rooms.js";
|
import { roomMoves, onlyPlace, weekPlan, partsByWeek } from "./rooms.js";
|
||||||
import { placeNote, lessonPlace, placeOf } from "./places.js";
|
import { placeNote, lessonPlace, placeOf } from "./places.js";
|
||||||
const run = promisify(execFile);
|
const run = promisify(execFile);
|
||||||
export const PARSER_VERSION = 15;
|
export const PARSER_VERSION = 17;
|
||||||
const days = [
|
const days = [
|
||||||
"понедельник",
|
"понедельник",
|
||||||
"вторник",
|
"вторник",
|
||||||
@@ -206,17 +206,89 @@ function byKind(text) {
|
|||||||
return [`${weeks(m[1])} ${at(m[2])}`, `${weeks(m[3])} ${at(m[4])}`];
|
return [`${weeks(m[1])} ${at(m[2])}`, `${weeks(m[3])} ${at(m[4])}`];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Who a lesson is for: "(для иностранных студентов)", "Русский язык как иностранный" are for
|
||||||
|
// foreign students only, "(кроме иностр.студ.)" for the others.
|
||||||
|
const FOR_FOREIGN = /\(\s*для\s+иностр[^)]*\)/iu;
|
||||||
|
const NOT_FOREIGN = /\(\s*кроме\s+иностр[^)]*\)/iu;
|
||||||
|
const FOREIGN_SUBJECT = /как\s+иностранн/iu;
|
||||||
|
export function audienceOf(text) {
|
||||||
|
if (NOT_FOREIGN.test(text)) return "local";
|
||||||
|
if (FOR_FOREIGN.test(text) || FOREIGN_SUBJECT.test(text)) return "foreign";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* One cell, two lessons at once: Russian for foreign students while the others have another
|
||||||
|
* lesson. "Ф и з и ч е с к а я к у л ь т у р а\nРусский язык как иностранный (для иностранных
|
||||||
|
* студентов) Семченко Е.Д. ауд.313н" or "Русский язык как иностранный (п.з.) Немирская В.А. 331Е
|
||||||
|
* ФИЗИЧЕСКАЯ КУЛЬТУРА". A line with only the teacher below the lesson ("Хаткевич Т.В. ауд. 323н")
|
||||||
|
* is no lesson of its own.
|
||||||
|
*/
|
||||||
|
// "Новополоцк" or "17.00" before a lesson is its town or time, not another lesson.
|
||||||
|
const aLesson = (text) =>
|
||||||
|
/(?:[А-ЯЁа-яё]\s?){3}/u.test(lessonPlace(text).rest.replace(/\d{1,2}[.:]\d{2}|(?:г\.\s*)?(?:ново)?полоцк[а-яё]*/giu, ""));
|
||||||
|
export function byAudience(text) {
|
||||||
|
const lines = text.split("\n");
|
||||||
|
const at = lines.findIndex(
|
||||||
|
(line, i) => i > 0 && audienceOf(line) === "foreign" && !/^\s*[А-ЯЁ][а-яё-]+\s+[А-ЯЁ]\.\s*[А-ЯЁ]\./u.test(line),
|
||||||
|
);
|
||||||
|
const head = lines.slice(0, at).join("\n");
|
||||||
|
if (at > 0 && audienceOf(head) !== "foreign" && aLesson(head))
|
||||||
|
return [
|
||||||
|
{ text: lines.slice(0, at).join("\n").trim(), audience: "local" },
|
||||||
|
{ text: lines.slice(at).join("\n").trim(), audience: "foreign" },
|
||||||
|
];
|
||||||
|
// "… Немирская В.А. 331Е со 2 нед. ФИЗИЧЕСКАЯ КУЛЬТУРА": the others' lesson in capitals after it.
|
||||||
|
const tail = text.match(
|
||||||
|
/^([\s\S]*?(?:\d{2,3}\s*[А-ЯЁа-яё]?|[А-ЯЁ]\.))\s+((?:(?:с|со)\s*)?(?:\d{1,2}(?:\s*[-–,]\s*\d{1,2})*\s*н(?:ед)?\.?\s+)?[А-ЯЁ]{3,}(?:\s+[А-ЯЁ]{3,})+)\s*$/u,
|
||||||
|
);
|
||||||
|
if (tail && audienceOf(tail[1]) === "foreign" && !audienceOf(tail[2]))
|
||||||
|
return [
|
||||||
|
{ text: tail[1].trim(), audience: "foreign" },
|
||||||
|
{ text: tail[2].trim(), audience: "local" },
|
||||||
|
];
|
||||||
|
// "Ф и з и ч е с к а я к у л ь т у р а 24-КФ: Русский язык как иностранный …" on one line.
|
||||||
|
const inline = text.match(/^((?:[А-ЯЁа-яё]\s?){3}[^\n]*?)\s+((?:\d{2}-[^\s:]+:\s*)?Русский\s+язык\s+как\s+иностранн[\s\S]*)$/u);
|
||||||
|
if (inline && !audienceOf(inline[1]) && aLesson(inline[1]))
|
||||||
|
return [
|
||||||
|
{ text: inline[1].trim(), audience: "local" },
|
||||||
|
{ text: inline[2].trim(), audience: "foreign" },
|
||||||
|
];
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// "25-ДОи", "24-ТНГи-2": a group of foreign students.
|
||||||
|
const FOREIGN_GROUP = /[А-ЯЁа-яё]и(?:-\d+)?$/u;
|
||||||
|
// A line that is a whole lesson: a subject, its kind in brackets and a teacher.
|
||||||
|
const WHOLE_LESSON = /^\s*[А-ЯЁ][^\n]*?\((?:л|п\.?\s*з|л\.?\s*з|лаб|пр|сем)\.?\)[^\n]*?[А-ЯЁ][а-яё-]+\s+[А-ЯЁ]\.\s*[А-ЯЁ]\./u;
|
||||||
|
/**
|
||||||
|
* "Немецкий язык (п.з) – Найверт Ю.О. 208А,\nФранцузский язык (п.з.) – Глобенко М.В. 336Е":
|
||||||
|
* one cell over the whole group, two lessons at once for the parts of the group that study one
|
||||||
|
* or the other. A line without a subject of its own ("321Д") stays with the lesson above.
|
||||||
|
*/
|
||||||
|
export function parallel(text) {
|
||||||
|
const parts = [];
|
||||||
|
for (const line of text.split("\n")) {
|
||||||
|
if (!parts.length || WHOLE_LESSON.test(line)) parts.push(line);
|
||||||
|
else parts[parts.length - 1] += "\n" + line;
|
||||||
|
}
|
||||||
|
if (parts.length < 2 || !parts.every((p) => WHOLE_LESSON.test(p))) return [text];
|
||||||
|
return parts.map((p) => p.trim().replace(/[,;]+$/, ""));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A cell's entries, each `{text, main, plan}`: the whole text, its first line (subject, kind,
|
* A cell's entries, each `{text, main, plan}`: the whole text, its first line (subject, kind,
|
||||||
* teacher and room are read from it) and the plan. A line that names only rooms or teachers by week
|
* teacher and room are read from it) and the plan. A line that names only rooms or teachers by week
|
||||||
* ("1-9н. 502н", "с 10н. – 359", "2-9н.-Клос О.Ю., 10-14н.-Конторов В.Э.") belongs to the lesson
|
* ("1-9н. 502н", "с 10н. – 359", "2-9н.-Клос О.Ю., 10-14н.-Конторов В.Э.") belongs to the lesson
|
||||||
* above: its plan of rooms and teachers by week. A line with weeks and a kind but no subject
|
* above: its plan of rooms and teachers by week. A line with weeks and a kind but no subject
|
||||||
* ("7,9,11н. – (п.з.) Савицкая К.Д. ауд. А1-06 8,12,14н. – (п.з.) …") is the subject above in
|
* ("7,9,11н. – (п.з.) Савицкая К.Д. ауд. А1-06 8,12,14н. – (п.з.) …") is the subject above in
|
||||||
* other weeks. A stray "." is no lesson.
|
* other weeks. A stray "." is no lesson. Lessons side by side for parts of the group ([parallel])
|
||||||
|
* also carry `choice`: the subjects to choose from.
|
||||||
*/
|
*/
|
||||||
export function entries(raw) {
|
export function entries(raw) {
|
||||||
const out = [];
|
const out = [];
|
||||||
for (const alternative of alternatives(raw).flatMap(byKind)) {
|
for (const part of alternatives(raw).flatMap(byKind)) {
|
||||||
|
// "…Третьяк З.И. 331Е,\n2-13н. …": the comma before the next lesson is not part of the room.
|
||||||
|
const alternative = part.replace(/[,;]\s*$/, "");
|
||||||
if (!/[\p{L}\d]/u.test(alternative)) continue;
|
if (!/[\p{L}\d]/u.test(alternative)) continue;
|
||||||
const prev = out.at(-1);
|
const prev = out.at(-1);
|
||||||
if (prev && onlyPlace(alternative)) {
|
if (prev && onlyPlace(alternative)) {
|
||||||
@@ -238,6 +310,24 @@ export function entries(raw) {
|
|||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
const options = parallel(alternative);
|
||||||
|
// "Теория языка (кроме иностр.студ.) … / История языка (для иностр.студ.) …": not a choice,
|
||||||
|
// each lesson is for its own students.
|
||||||
|
const marks = options.map(audienceOf);
|
||||||
|
if (options.length > 1 && marks.every(Boolean)) {
|
||||||
|
options.forEach((o, i) => out.push({ text: o, main: o, plan: [], audience: marks[i] }));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const split = byAudience(alternative);
|
||||||
|
if (split) {
|
||||||
|
for (const p of split) out.push({ text: p.text, main: p.text, plan: weekPlan(p.text).filter((w) => w.color), audience: p.audience });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (options.length > 1) {
|
||||||
|
const choice = options.map((o) => details(o).subject);
|
||||||
|
for (const o of options) out.push({ text: o, main: o, plan: [], choice });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
// "бел.н.504н/зел.н.524н": a room for white weeks and one for green ones.
|
// "бел.н.504н/зел.н.524н": a room for white weeks and one for green ones.
|
||||||
out.push({ text: alternative, main: alternative, plan: weekPlan(alternative).filter((p) => p.color) });
|
out.push({ text: alternative, main: alternative, plan: weekPlan(alternative).filter((p) => p.color) });
|
||||||
}
|
}
|
||||||
@@ -400,6 +490,8 @@ export function parseDocx(buffer, source = {}) {
|
|||||||
const lessons = [],
|
const lessons = [],
|
||||||
// "Занятия в Полоцке": the days a group, or one of its subgroups, spends in another town.
|
// "Занятия в Полоцке": the days a group, or one of its subgroups, spends in another town.
|
||||||
places = [],
|
places = [],
|
||||||
|
// Subjects that parts of a group choose between: the second foreign language, an elective.
|
||||||
|
choices = [],
|
||||||
groups = new Set(),
|
groups = new Set(),
|
||||||
warnings = new Set();
|
warnings = new Set();
|
||||||
// The catalog folder ("2026–2027") wins: some files keep last year's header text.
|
// The catalog folder ("2026–2027") wins: some files keep last year's header text.
|
||||||
@@ -576,11 +668,23 @@ export function parseDocx(buffer, source = {}) {
|
|||||||
raw = note.rest;
|
raw = note.rest;
|
||||||
if (!raw.trim()) return;
|
if (!raw.trim()) return;
|
||||||
}
|
}
|
||||||
for (const { text: whole, main: alternative, plan } of entries(raw)) {
|
for (const { text: whole, main, plan, choice, audience } of entries(raw)) {
|
||||||
|
if (choice) choices.push({ groups: targetGroups, options: choice });
|
||||||
|
// "24-КФ: Русский язык как иностранный": one of the cell's groups.
|
||||||
|
let alternative = main;
|
||||||
|
let lessonGroups = targetGroups;
|
||||||
|
const named = main.match(/^\s*([^:\n]{4,24}):\s+(?=\S)/u);
|
||||||
|
const only = named ? groupsIn(named[1]) : [];
|
||||||
|
if (only.length && only.every((g) => targetGroups.includes(g))) {
|
||||||
|
lessonGroups = only;
|
||||||
|
alternative = main.slice(named[0].length);
|
||||||
|
}
|
||||||
// "Единый день информирования 10.09;15.10;19.11;17.12": the dates are not part of the
|
// "Единый день информирования 10.09;15.10;19.11;17.12": the dates are not part of the
|
||||||
// subject, nor is a move to another room on a date ("412н 26.10.2026-333").
|
// subject, nor is a move to another room on a date ("412н 26.10.2026-333").
|
||||||
const read = (text) => {
|
const read = (text) => {
|
||||||
const data = details(roomMoves(text).rest.replace(DATE_LIST, "").replace(/[ \t]{2,}/g, " ").trim());
|
// "(для иностранных студентов)" is who the lesson is for, no part of its name or room.
|
||||||
|
const plain = text.replace(FOR_FOREIGN, " ").replace(NOT_FOREIGN, " ");
|
||||||
|
const data = details(roomMoves(plain).rest.replace(DATE_LIST, "").replace(/[ \t]{2,}/g, " ").trim());
|
||||||
if (credits && data.type === "Занятие") data.type = "Зачёт";
|
if (credits && data.type === "Занятие") data.type = "Зачёт";
|
||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
@@ -618,7 +722,7 @@ export function parseDocx(buffer, source = {}) {
|
|||||||
...(start && { start, end: lead.end }),
|
...(start && { start, end: lead.end }),
|
||||||
...data,
|
...data,
|
||||||
...overrides,
|
...overrides,
|
||||||
groups: targetGroups,
|
groups: lessonGroups,
|
||||||
subgroups,
|
subgroups,
|
||||||
variant: spansBoth ? "all" : variant,
|
variant: spansBoth ? "all" : variant,
|
||||||
weekRanges:
|
weekRanges:
|
||||||
@@ -633,6 +737,10 @@ export function parseDocx(buffer, source = {}) {
|
|||||||
? cell.color
|
? cell.color
|
||||||
: null,
|
: null,
|
||||||
raw: whole,
|
raw: whole,
|
||||||
|
...(choice && { option: data.subject }),
|
||||||
|
// Groups of foreign students alone ("25-ДОи") have Russian as everyone's lesson.
|
||||||
|
...((audience || audienceOf(whole)) &&
|
||||||
|
!lessonGroups.every(FOREIGN_GROUP.test.bind(FOREIGN_GROUP)) && { audience: audience || audienceOf(whole) }),
|
||||||
// Rooms and teachers by week, or by the week's colour.
|
// Rooms and teachers by week, or by the week's colour.
|
||||||
...(plan.length && { plan }),
|
...(plan.length && { plan }),
|
||||||
});
|
});
|
||||||
@@ -696,6 +804,19 @@ export function parseDocx(buffer, source = {}) {
|
|||||||
const labels = new Map();
|
const labels = new Map();
|
||||||
for (const l of lessons)
|
for (const l of lessons)
|
||||||
for (const s of l.subgroups) labels.set(s.group, new Set([...(labels.get(s.group) || []), s.label]));
|
for (const s of l.subgroups) labels.set(s.group, new Set([...(labels.get(s.group) || []), s.label]));
|
||||||
|
// The chosen subject on its own in a cell ("Немецкий язык" on Thursday) is the same choice:
|
||||||
|
// only that part of the group has it.
|
||||||
|
const norm = (s) => s.toLowerCase().replace(/\s+/g, " ").trim();
|
||||||
|
const sets = [];
|
||||||
|
for (const c of choices) {
|
||||||
|
const key = c.groups.join("|") + "#" + c.options.map(norm).sort().join("|");
|
||||||
|
if (!sets.some((s) => s.key === key)) sets.push({ key, groups: c.groups, options: c.options });
|
||||||
|
}
|
||||||
|
for (const lesson of lessons) {
|
||||||
|
if (lesson.option || lesson.subgroups.length) continue;
|
||||||
|
const set = sets.find((s) => s.groups.some((g) => lesson.groups.includes(g)) && s.options.some((o) => norm(o) === norm(lesson.subject)));
|
||||||
|
if (set) lesson.option = set.options.find((o) => norm(o) === norm(lesson.subject));
|
||||||
|
}
|
||||||
for (const lesson of lessons) {
|
for (const lesson of lessons) {
|
||||||
if (lesson.place || !places.length) continue;
|
if (lesson.place || !places.length) continue;
|
||||||
const place = placeOf(lesson, places, (group) => [...(labels.get(group) || [])]);
|
const place = placeOf(lesson, places, (group) => [...(labels.get(group) || [])]);
|
||||||
@@ -708,6 +829,7 @@ export function parseDocx(buffer, source = {}) {
|
|||||||
(a, b) => a.day - b.day || a.start.localeCompare(b.start),
|
(a, b) => a.day - b.day || a.start.localeCompare(b.start),
|
||||||
),
|
),
|
||||||
...(places.length && { places }),
|
...(places.length && { places }),
|
||||||
|
...(sets.length && { choices: sets.map(({ groups, options }) => ({ groups, options })) }),
|
||||||
notes: document.notes,
|
notes: document.notes,
|
||||||
warnings: [...warnings],
|
warnings: [...warnings],
|
||||||
source: {
|
source: {
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ function lessonOut(lesson, group, people, date, status, calendar, week = null) {
|
|||||||
condition: lesson.condition,
|
condition: lesson.condition,
|
||||||
variant: lesson.variant,
|
variant: lesson.variant,
|
||||||
subgroup: lesson.subgroups.find((s) => s.group === group)?.label || null,
|
subgroup: lesson.subgroups.find((s) => s.group === group)?.label || null,
|
||||||
|
// "Немецкий язык" when part of the group studies German and part French (see `choices`).
|
||||||
|
option: lesson.option || null,
|
||||||
|
// "foreign": for foreign students only (Russian as a foreign language); "local": for the others.
|
||||||
|
audience: lesson.audience || null,
|
||||||
groups: lesson.groups,
|
groups: lesson.groups,
|
||||||
uncertain: status === "maybe",
|
uncertain: status === "maybe",
|
||||||
raw: lesson.raw,
|
raw: lesson.raw,
|
||||||
@@ -159,6 +163,10 @@ export function timetable({ file, schedule, calendar, group, from, days, range,
|
|||||||
...extra.map((e) => e.lesson.subgroups?.find((s) => s.group === group)?.label).filter(Boolean),
|
...extra.map((e) => e.lesson.subgroups?.find((s) => s.group === group)?.label).filter(Boolean),
|
||||||
]),
|
]),
|
||||||
].sort((a, b) => a.localeCompare(b, "ru", { numeric: true })),
|
].sort((a, b) => a.localeCompare(b, "ru", { numeric: true })),
|
||||||
|
// What parts of the group choose between: [["Немецкий язык", "Французский язык"]].
|
||||||
|
choices: (schedule.choices || []).filter((c) => c.groups.includes(group)).map((c) => c.options),
|
||||||
|
// Some lessons are for foreign students only, or for the others: worth asking which one you are.
|
||||||
|
audience: lessons.some((l) => l.audience),
|
||||||
bells: calendar?.bells || [],
|
bells: calendar?.bells || [],
|
||||||
from,
|
from,
|
||||||
days: out,
|
days: out,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { groupsIn, alternatives, entries, details, leadingTime } from "../server/parser.js";
|
import { groupsIn, alternatives, entries, details, leadingTime, parallel, audienceOf } from "../server/parser.js";
|
||||||
import { roomMoves, weekPlan, onlyPlace } from "../server/rooms.js";
|
import { roomMoves, weekPlan, onlyPlace } from "../server/rooms.js";
|
||||||
import { peopleAndRooms, roomsOn, teachersOn, movedOn } from "../server/directory.js";
|
import { peopleAndRooms, roomsOn, teachersOn, movedOn } from "../server/directory.js";
|
||||||
|
|
||||||
@@ -118,6 +118,40 @@ test("Подгруппа в тексте ячейки, точка вместо
|
|||||||
assert.equal(details("Интеллектуальная собственность - драйвер инновационного развития\nна пространстве Евразии").subject.includes("Евразии"), true);
|
assert.equal(details("Интеллектуальная собственность - драйвер инновационного развития\nна пространстве Евразии").subject.includes("Евразии"), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Немецкий или французский: два занятия в одной ячейке на всю группу", () => {
|
||||||
|
const cell = "Немецкий язык (п.з) – Найверт Ю.О. 208А,\nФранцузский язык (п.з.) – Глобенко М.В. 336Е";
|
||||||
|
const list = entries(cell);
|
||||||
|
assert.deepEqual(list.map((e) => details(e.main).room), ["208А", "336Е"]);
|
||||||
|
assert.deepEqual(list.map((e) => details(e.main).teacher), ["Найверт Ю.О.", "Глобенко М.В."]);
|
||||||
|
assert.deepEqual(list[0].choice, ["Немецкий язык", "Французский язык"]);
|
||||||
|
// A room on its own line stays with its lesson.
|
||||||
|
assert.deepEqual(
|
||||||
|
parallel("Теория языка (англ.) (л.) (кроме иностр.студ.)-Чеботарева Е.М.305Г\nИстория языка (англ.) (л) (для иностр.студ.) Третьяк З.И.\n321Д").at(-1),
|
||||||
|
"История языка (англ.) (л) (для иностр.студ.) Третьяк З.И.\n321Д",
|
||||||
|
);
|
||||||
|
// A lesson with its teacher on the next line is one lesson.
|
||||||
|
assert.equal(entries("Русский язык как иностранный (п.з.)\nНемирская В.А. 305Г").length, 1);
|
||||||
|
// The comma before the next week's lesson is no part of the room.
|
||||||
|
assert.equal(details(entries("2-13н. Белорусский язык (п.з.) - Третьяк З.И. 331Е,\n2-13н. Стилистика (п.з.) – Коротких А.В. 301Г")[0].main).room, "331Е");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Пары для иностранных студентов и для остальных", () => {
|
||||||
|
assert.equal(audienceOf("История языка (п.з.) (кроме иностр.студ.) Ковшик О.С. 305Г"), "local");
|
||||||
|
assert.equal(audienceOf("Русский язык как иностранный (п.з.) Немирская В.А. 305Г"), "foreign");
|
||||||
|
assert.equal(audienceOf("Современный разговорный иностранный язык (на базе второго иностранного языка)"), null);
|
||||||
|
// Russian for foreign students while the others have physical education, in every spelling.
|
||||||
|
const pe = (text) => entries(text).map((e) => [details(e.main).subject, e.audience]);
|
||||||
|
assert.deepEqual(pe("Ф и з и ч е с к а я к у л ь т у р а\nРусский язык как иностранный (для иностранных студентов) Семченко Е.Д. ауд.313н"), [
|
||||||
|
["Ф и з и ч е с к а я к у л ь т у р а", "local"],
|
||||||
|
["Русский язык как иностранный (для иностранных студентов)", "foreign"],
|
||||||
|
]);
|
||||||
|
assert.deepEqual(pe("со 2 нед. Русский язык как иностранный (п.з.) Немирская В.А. 331Е со 2 нед. ФИЗИЧЕСКАЯ КУЛЬТУРА").map((p) => p[1]), ["foreign", "local"]);
|
||||||
|
assert.equal(details(entries("Русский язык как иностранный (п.з.) Немирская В.А. 331Е ФИЗИЧЕСКАЯ КУЛЬТУРА")[0].main).room, "331Е");
|
||||||
|
// The town or the teacher on a line of its own is no lesson for the others.
|
||||||
|
assert.equal(entries("17.00 Новополоцк\nРусский язык как иностранный (п.з.)\nКириллова В.С. ауд. 537н").length, 1);
|
||||||
|
assert.equal(entries("Русский язык как иностранный (для иностранных студентов)\nХаткевич Т.В. ауд. 323н").length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
test("Старое имя разделённой группы: понятная ошибка, другое написание — та же группа", async () => {
|
test("Старое имя разделённой группы: понятная ошибка, другое написание — та же группа", async () => {
|
||||||
const { timetable } = await import("../server/timetable.js");
|
const { timetable } = await import("../server/timetable.js");
|
||||||
const schedule = { groups: ["24-ИЯ (РА)и-1", "24-ИЯ (РА)и-2"], lessons: [], notes: [], period: "Осенний семестр", source: {} };
|
const schedule = { groups: ["24-ИЯ (РА)и-1", "24-ИЯ (РА)и-2"], lessons: [], notes: [], period: "Осенний семестр", source: {} };
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
вт 15:20–16:45 | каждую неделю, нижняя строка | Физический практикум: физика атома | Занятие | 210н | Окунев Д.О. | 24-КФ,24-КФ/п2
|
вт 15:20–16:45 | каждую неделю, нижняя строка | Физический практикум: физика атома | Занятие | 210н | Окунев Д.О. | 24-КФ,24-КФ/п2
|
||||||
вт 15:20–16:45 | нед. 2-14, верхняя строка | Криптографические методы защиты информации | Лабораторная | 421н | Кухта С.В. | 24-КБ,24-КБ/п2
|
вт 15:20–16:45 | нед. 2-14, верхняя строка | Криптографические методы защиты информации | Лабораторная | 421н | Кухта С.В. | 24-КБ,24-КБ/п2
|
||||||
вт 17:00–18:25 | нед. 1-13, верхняя строка | Криптографические методы защиты информации | Лабораторная | 421н | Кухта С.В. | 24-КБ,24-КБ/п1
|
вт 17:00–18:25 | нед. 1-13, верхняя строка | Криптографические методы защиты информации | Лабораторная | 421н | Кухта С.В. | 24-КБ,24-КБ/п1
|
||||||
пн 08:30–09:55 | каждую неделю | Русский язык как иностранный (для иностранных студентов) | Занятие | 412н | Козыренко А.Н. | 24-КБ
|
пн 08:30–09:55 | каждую неделю | Русский язык как иностранный | Занятие | 412н | Козыренко А.Н. | 24-КБ · иностранцам
|
||||||
пн 10:05–11:30 | каждую неделю | Уравнения математической физики | Лекция | 336н | Козлов А.А. | 24-КБ
|
пн 10:05–11:30 | каждую неделю | Уравнения математической физики | Лекция | 336н | Козлов А.А. | 24-КБ
|
||||||
пн 11:45–13:10 | каждую неделю | Операционные системы | Лекция | 343 | Бураченок А.Л. | 24-КБ
|
пн 11:45–13:10 | каждую неделю | Операционные системы | Лекция | 343 | Бураченок А.Л. | 24-КБ
|
||||||
пн 11:45–13:10 | каждую неделю | Электродинамика | Лекция | 402н | Вабищевич С.А. | 24-КФ
|
пн 11:45–13:10 | каждую неделю | Электродинамика | Лекция | 402н | Вабищевич С.А. | 24-КФ
|
||||||
@@ -60,7 +60,8 @@
|
|||||||
сб 11:45–13:10 | каждую неделю, верхняя строка | Компьютерные сети | Лабораторная | 215н | Филиппов Ю.И. | 24-КБ,24-КБ/п1
|
сб 11:45–13:10 | каждую неделю, верхняя строка | Компьютерные сети | Лабораторная | 215н | Филиппов Ю.И. | 24-КБ,24-КБ/п1
|
||||||
сб 11:45–13:10 | каждую неделю, нижняя строка | Компьютерные сети | Лабораторная | 215н | Филиппов Ю.И. | 24-КБ,24-КБ/п2
|
сб 11:45–13:10 | каждую неделю, нижняя строка | Компьютерные сети | Лабораторная | 215н | Филиппов Ю.И. | 24-КБ,24-КБ/п2
|
||||||
ср 10:05–11:30 | нед. 2-14 | Модуль «Майнор» | Занятие | | | 24-КБ,24-КФ
|
ср 10:05–11:30 | нед. 2-14 | Модуль «Майнор» | Занятие | | | 24-КБ,24-КФ
|
||||||
чт 08:30–09:55 | каждую неделю | Ф и з и ч е с к а я к у л ь т у р а 24-КФ: Русский язык как иностранный (для иностранных студентов) | Занятие | 537н | Кириллова В.С. | 24-КБ,24-КФ
|
чт 08:30–09:55 | каждую неделю | Русский язык как иностранный | Занятие | 537н | Кириллова В.С. | 24-КФ · иностранцам
|
||||||
|
чт 08:30–09:55 | каждую неделю | Ф и з и ч е с к а я к у л ь т у р а | Занятие | | | 24-КБ,24-КФ · кроме иностранцев
|
||||||
чт 10:05–11:30 | каждую неделю, верхняя строка | Криптографические методы защиты информации | Лекция | 329н | Кухта С.В. | 24-КБ
|
чт 10:05–11:30 | каждую неделю, верхняя строка | Криптографические методы защиты информации | Лекция | 329н | Кухта С.В. | 24-КБ
|
||||||
чт 10:05–11:30 | каждую неделю, верхняя строка | Основы математического моделирования | Лабораторная | 215н | Сковородко М.А. | 24-КФ,24-КФ/п1
|
чт 10:05–11:30 | каждую неделю, верхняя строка | Основы математического моделирования | Лабораторная | 215н | Сковородко М.А. | 24-КФ,24-КФ/п1
|
||||||
чт 10:05–11:30 | каждую неделю, верхняя строка | Основы радиоэлектроники | Лабораторная | 406н | Грозберг Ю.Г. | 24-КФ,24-КФ/п2
|
чт 10:05–11:30 | каждую неделю, верхняя строка | Основы радиоэлектроники | Лабораторная | 406н | Грозберг Ю.Г. | 24-КФ,24-КФ/п2
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
# Осенний семестр · 25-РГФ-1, 25-РГФ-2
|
# Осенний семестр · 25-РГФ-1, 25-РГФ-2
|
||||||
вт 08:30–09:55 | каждую неделю | Немецкий язык | Практика | 336Е | Найверт Ю.О. | 25-РГФ-1
|
вт 08:30–09:55 | каждую неделю | Немецкий язык | Практика | 319Д | Найверт Ю.О. | 25-РГФ-1 · на выбор
|
||||||
|
вт 08:30–09:55 | каждую неделю | Французский язык | Практика | 336Е | Глобенко М.В. | 25-РГФ-1 · на выбор
|
||||||
вт 10:05–11:30 | нед. 2-14 | Философия | Практика | 206Г | Андрейчик Е.В. | 25-РГФ-1,25-РГФ-2
|
вт 10:05–11:30 | нед. 2-14 | Философия | Практика | 206Г | Андрейчик Е.В. | 25-РГФ-1,25-РГФ-2
|
||||||
вт 11:45–13:10 | каждую неделю | Английский язык | Практика | 133Е | Лагун А.М. | 25-РГФ-2
|
вт 11:45–13:10 | каждую неделю | Английский язык | Практика | 133Е | Лагун А.М. | 25-РГФ-2
|
||||||
вт 11:45–13:10 | нед. 1-13 | История литературы страны изучаемого языка XVII века | Лекция | 305Г | Смулькевич А.А. | 25-РГФ-1
|
вт 11:45–13:10 | нед. 1-13 | История литературы страны изучаемого языка XVII века | Лекция | 305Г | Смулькевич А.А. | 25-РГФ-1
|
||||||
@@ -10,18 +11,20 @@
|
|||||||
вт 15:20–16:45 | каждую неделю, нижняя строка | Современный разговорный иностранный язык (англ.) | Практика | 203Г | Ковшик О.С. | 25-РГФ-1,25-РГФ-1/п1
|
вт 15:20–16:45 | каждую неделю, нижняя строка | Современный разговорный иностранный язык (англ.) | Практика | 203Г | Ковшик О.С. | 25-РГФ-1,25-РГФ-1/п1
|
||||||
вт 15:20–16:45 | каждую неделю, нижняя строка | Современный разговорный иностранный язык (англ.) | Практика | 304Г | Федорович Н.Я. | 25-РГФ-1,25-РГФ-1/п2
|
вт 15:20–16:45 | каждую неделю, нижняя строка | Современный разговорный иностранный язык (англ.) | Практика | 304Г | Федорович Н.Я. | 25-РГФ-1,25-РГФ-1/п2
|
||||||
вт 15:20–16:45 | нед. с 3, верхняя строка | Политология | Практика | 206Г | Радюк А.Л. | 25-РГФ-1,25-РГФ-2
|
вт 15:20–16:45 | нед. с 3, верхняя строка | Политология | Практика | 206Г | Радюк А.Л. | 25-РГФ-1,25-РГФ-2
|
||||||
пн 08:30–09:55 | каждую неделю | Русский язык как иностранный | Практика | 305Г | Немирская В.А. | 25-РГФ-1,25-РГФ-1/п1
|
пн 08:30–09:55 | каждую неделю | Русский язык как иностранный | Практика | 305Г | Немирская В.А. | 25-РГФ-1,25-РГФ-1/п1 · иностранцам
|
||||||
пн 10:05–11:30 | каждую неделю | Немецкий язык | Практика | 336Е | Найверт Ю.О. | 25-РГФ-1
|
пн 10:05–11:30 | каждую неделю | Немецкий язык | Практика | 208А | Найверт Ю.О. | 25-РГФ-1 · на выбор
|
||||||
пн 10:05–11:30 | каждую неделю | Основы перевода | Практика | 331Е | Семченок Л.И. | 25-РГФ-2
|
пн 10:05–11:30 | каждую неделю | Основы перевода | Практика | 331Е | Семченок Л.И. | 25-РГФ-2
|
||||||
|
пн 10:05–11:30 | каждую неделю | Французский язык | Практика | 336Е | Глобенко М.В. | 25-РГФ-1 · на выбор
|
||||||
пн 11:45–13:10 | каждую неделю | ФИЗИЧЕСКАЯ КУЛЬТУРА | Занятие | | | 25-РГФ-1,25-РГФ-2
|
пн 11:45–13:10 | каждую неделю | ФИЗИЧЕСКАЯ КУЛЬТУРА | Занятие | | | 25-РГФ-1,25-РГФ-2
|
||||||
пн 13:40–15:05 | каждую неделю | Практика устной и письменной речи (англ.) | Практика | 331Е | Федорович Н.Я. | 25-РГФ-1,25-РГФ-1/п2
|
пн 13:40–15:05 | каждую неделю | Практика устной и письменной речи (англ.) | Практика | 331Е | Федорович Н.Я. | 25-РГФ-1,25-РГФ-1/п2
|
||||||
пн 13:40–15:05 | каждую неделю | Практическая фонетика (англ) | Практика | 319Д | Кунчевская А.В. | 25-РГФ-1,25-РГФ-1/п1
|
пн 13:40–15:05 | каждую неделю | Практическая фонетика (англ) | Практика | 319Д | Кунчевская А.В. | 25-РГФ-1,25-РГФ-1/п1
|
||||||
пн 13:40–15:05 | каждую неделю | Практическая фонетика (нем) | Практика | 330Е | Логвинова И.В. | 25-РГФ-2
|
пн 13:40–15:05 | каждую неделю | Практическая фонетика (нем) | Практика | 330Е | Логвинова И.В. | 25-РГФ-2
|
||||||
пн 15:20–16:45 | каждую неделю | Английский язык | Практика | 248Ж | Лагун А.М. | 25-РГФ-2
|
пн 15:20–16:45 | каждую неделю | Английский язык | Практика | 248Ж | Лагун А.М. | 25-РГФ-2
|
||||||
пн 15:20–16:45 | каждую неделю | Немецкий язык | Практика | 336Е | Найверт Ю.О. | 25-РГФ-1
|
пн 15:20–16:45 | каждую неделю | Немецкий язык | Практика | 332Е | Найверт Ю.О. | 25-РГФ-1 · на выбор
|
||||||
|
пн 15:20–16:45 | каждую неделю | Французский язык | Практика | 336Е | Глобенко М.В. | 25-РГФ-1 · на выбор
|
||||||
пт 08:30–09:55 | каждую неделю | Основы перевода | Практика | 208А | Семченок Л.И. | 25-РГФ-2
|
пт 08:30–09:55 | каждую неделю | Основы перевода | Практика | 208А | Семченок Л.И. | 25-РГФ-2
|
||||||
пт 08:30–09:55 | каждую неделю, верхняя строка | Русский язык как иностранный | Практика | 307Г | Немирская В.А. | 25-РГФ-1,25-РГФ-1/п1
|
пт 08:30–09:55 | каждую неделю, верхняя строка | Русский язык как иностранный | Практика | 307Г | Немирская В.А. | 25-РГФ-1,25-РГФ-1/п1 · иностранцам
|
||||||
пт 08:30–09:55 | каждую неделю, нижняя строка | Французский язык | Практика | 336Е | Глобенко М.В. | 25-РГФ-1
|
пт 08:30–09:55 | каждую неделю, нижняя строка | Французский язык | Практика | 336Е | Глобенко М.В. | 25-РГФ-1 · на выбор
|
||||||
пт 10:05–11:30 | каждую неделю | История зарубежной литературы Средних веков и Возрождения | Практика | 305Г | Лушневская Е.В. | 25-РГФ-1,25-РГФ-2
|
пт 10:05–11:30 | каждую неделю | История зарубежной литературы Средних веков и Возрождения | Практика | 305Г | Лушневская Е.В. | 25-РГФ-1,25-РГФ-2
|
||||||
пт 11:45–13:10 | каждую неделю | Основы перевода | Практика | 203Г | Смулькевич А.А. | 25-РГФ-1
|
пт 11:45–13:10 | каждую неделю | Основы перевода | Практика | 203Г | Смулькевич А.А. | 25-РГФ-1
|
||||||
пт 11:45–13:10 | нед. 1-13 | История литературы страны изучаемого языка XVII века | Лекция | 248Ж | Гордеенок Т.М. | 25-РГФ-2
|
пт 11:45–13:10 | нед. 1-13 | История литературы страны изучаемого языка XVII века | Лекция | 248Ж | Гордеенок Т.М. | 25-РГФ-2
|
||||||
@@ -43,19 +46,20 @@
|
|||||||
ср 10:05–11:30 | каждую неделю, нижняя строка | Коммуникативная грамматика | Практика | 304Г | Ковшик О.С. | 25-РГФ-1,25-РГФ-1/п1
|
ср 10:05–11:30 | каждую неделю, нижняя строка | Коммуникативная грамматика | Практика | 304Г | Ковшик О.С. | 25-РГФ-1,25-РГФ-1/п1
|
||||||
ср 10:05–11:30 | каждую неделю, нижняя строка | Коммуникативная грамматика | Практика | 308Г | Протас А.А. | 25-РГФ-1,25-РГФ-1/п2
|
ср 10:05–11:30 | каждую неделю, нижняя строка | Коммуникативная грамматика | Практика | 308Г | Протас А.А. | 25-РГФ-1,25-РГФ-1/п2
|
||||||
ср 10:05–11:30 | нед. 2-15 | История языка | Практика | 248Ж | Логвинова И.В. | 25-РГФ-2
|
ср 10:05–11:30 | нед. 2-15 | История языка | Практика | 248Ж | Логвинова И.В. | 25-РГФ-2
|
||||||
ср 11:45–13:10 | нед. с 2 | Русский язык как иностранный | Практика | | Немирская В.А. | 25-РГФ-1,25-РГФ-2
|
ср 11:45–13:10 | нед. с 2 | Русский язык как иностранный | Практика | 331Е | Немирская В.А. | 25-РГФ-1,25-РГФ-2 · иностранцам
|
||||||
ср 13:40–15:05 | нед. 2-15 | История языка | Практика | 305Г | Ковшик О.С. | 25-РГФ-1
|
ср 11:45–13:10 | нед. с 2 | ФИЗИЧЕСКАЯ КУЛЬТУРА | Занятие | | | 25-РГФ-1,25-РГФ-2 · кроме иностранцев
|
||||||
|
ср 13:40–15:05 | нед. 2-15 | История языка | Практика | 305Г | Ковшик О.С. | 25-РГФ-1 · кроме иностранцев
|
||||||
ср 15:20–16:45 | нед. 2-15 | Философия | Лекция | 211А | Борун Е.Н. | 25-РГФ-1,25-РГФ-2
|
ср 15:20–16:45 | нед. 2-15 | Философия | Лекция | 211А | Борун Е.Н. | 25-РГФ-1,25-РГФ-2
|
||||||
чт 08:30–09:55 | каждую неделю | Русский язык как иностранный | Практика | 307Г | Немирская В.А. | 25-РГФ-1,25-РГФ-1/п1
|
чт 08:30–09:55 | каждую неделю | Русский язык как иностранный | Практика | 307Г | Немирская В.А. | 25-РГФ-1,25-РГФ-1/п1 · иностранцам
|
||||||
чт 08:30–09:55 | каждую неделю, верхняя строка | Современный разговорный иностранный язык (англ.) | Практика | 304Г | Федорович Н.Я. | 25-РГФ-1,25-РГФ-1/п2
|
чт 08:30–09:55 | каждую неделю, верхняя строка | Современный разговорный иностранный язык (англ.) | Практика | 304Г | Федорович Н.Я. | 25-РГФ-1,25-РГФ-1/п2
|
||||||
чт 08:30–09:55 | каждую неделю, нижняя строка | Практика устной и письменной речи (англ.) | Практика | 304Г | Федорович Н.Я. | 25-РГФ-1,25-РГФ-1/п2
|
чт 08:30–09:55 | каждую неделю, нижняя строка | Практика устной и письменной речи (англ.) | Практика | 304Г | Федорович Н.Я. | 25-РГФ-1,25-РГФ-1/п2
|
||||||
чт 10:05–11:30 | каждую неделю, верхняя строка | История зарубежной литературы Средних веков и Возрождения | Лекция | 305Г | Лушневская Е.В. | 25-РГФ-1,25-РГФ-2
|
чт 10:05–11:30 | каждую неделю, верхняя строка | История зарубежной литературы Средних веков и Возрождения | Лекция | 305Г | Лушневская Е.В. | 25-РГФ-1,25-РГФ-2
|
||||||
чт 10:05–11:30 | каждую неделю, нижняя строка | История языка | Лекция | 305Г | Третьяк З.И. | 25-РГФ-1
|
чт 10:05–11:30 | каждую неделю, нижняя строка | История языка | Лекция | 305Г | Третьяк З.И. | 25-РГФ-1 · кроме иностранцев
|
||||||
чт 10:05–11:30 | каждую неделю, нижняя строка | Современный разговорный иностранный язык (нем.) | Практика | 248Ж | Белоусова Э.А. | 25-РГФ-2
|
чт 10:05–11:30 | каждую неделю, нижняя строка | Современный разговорный иностранный язык (нем.) | Практика | 248Ж | Белоусова Э.А. | 25-РГФ-2
|
||||||
чт 11:45–13:10 | каждую неделю | Английский язык | Практика | 248Ж | Лагун А.М. | 25-РГФ-2
|
чт 11:45–13:10 | каждую неделю | Английский язык | Практика | 248Ж | Лагун А.М. | 25-РГФ-2
|
||||||
чт 11:45–13:10 | каждую неделю | Основы перевода | Практика | 120Д | Смулькевич А.А. | 25-РГФ-1
|
чт 11:45–13:10 | каждую неделю | Основы перевода | Практика | 120Д | Смулькевич А.А. | 25-РГФ-1
|
||||||
чт 13:40–15:05 | даты 2026-09-10,2026-10-15,2026-11-19,2026-12-17 | Единый день информирования: 10 сентября, 15 октября, 19 ноября, 17 декабря | Занятие | | | 25-РГФ-1,25-РГФ-2
|
чт 13:40–15:05 | даты 2026-09-10,2026-10-15,2026-11-19,2026-12-17 | Единый день информирования: 10 сентября, 15 октября, 19 ноября, 17 декабря | Занятие | | | 25-РГФ-1,25-РГФ-2
|
||||||
чт 13:40–15:05 | каждую неделю | Практика устной и письменной речи (нем.) | Практика | 248Ж | Белоусова Э.А. | 25-РГФ-2
|
чт 13:40–15:05 | каждую неделю | Практика устной и письменной речи (нем.) | Практика | 248Ж | Белоусова Э.А. | 25-РГФ-2
|
||||||
чт 13:40–15:05 | нед. 1-13 | История литературы страны изучаемого языка XVII века | Практика | 319Д | Смулькевич А.А. | 25-РГФ-1
|
чт 13:40–15:05 | нед. 1-13 | История литературы страны изучаемого языка XVII века | Практика | 319Д | Смулькевич А.А. | 25-РГФ-1
|
||||||
чт 15:20–16:45 | каждую неделю | Немецкий язык | Практика | 208А | Найверт Ю.О. | 25-РГФ-1
|
чт 15:20–16:45 | каждую неделю | Немецкий язык | Практика | 208А | Найверт Ю.О. | 25-РГФ-1 · на выбор
|
||||||
чт 15:20–16:45 | каждую неделю | Практическая грамматика (нем.) | Практика | 248Ж | Белоусова Э.А. | 25-РГФ-2
|
чт 15:20–16:45 | каждую неделю | Практическая грамматика (нем.) | Практика | 248Ж | Белоусова Э.А. | 25-РГФ-2
|
||||||
|
|||||||
Reference in New Issue
Block a user