feat: Add Settings and Subjects screens with theme reveal functionality
- Implemented SettingsScreen for user preferences including theme selection, reminders, and notifications. - Created SubjectsScreen to display subjects with upcoming lessons and associated details. - Introduced ThemeReveal for a smooth transition between light and dark themes. - Added support for managing notes and tasks related to subjects. - Enhanced UI components for better user experience and accessibility.
This commit is contained in:
19
README.md
19
README.md
@@ -158,6 +158,8 @@ npm run sync
|
||||
- Строка с предметом и аудиторией над строками по видам — заголовок: её аудитория переходит к занятиям под ней.
|
||||
- **Совмещённый вид.** «(л./п.з.)» и «(л. и п.з.)» — вид «Лекция и практика».
|
||||
- **Подгруппа в тексте.** «12н.2п/гр. Экономика…» — занятие одной подгруппы.
|
||||
- **Разрядка** (парсер 19): «Ф и з и ч е с к а я к у л ь т у р а», «З а н я т и я в П о л о ц к е» — буквы через пробел, слова через несколько. Склеивается до сжатия пробелов: «Физическая культура», «Занятия в Полоцке» (теперь это и заметка о городе). Инициалы и короткие слова не трогаются.
|
||||
- **«со 2 по 14 нед.»** читается как «2-14 нед.»; пробелы внутри кавычек («« Майнор »») убираются.
|
||||
- **Вид занятия после преподавателя** (парсер 18): «Русский язык как иностранный Козыренко А.Н.(п.з.) ауд.100» — название кончается там, где начинается преподаватель или вид, смотря что раньше. Раньше фамилия попадала в название (25‑ЭПи, 25‑ЭМ).
|
||||
- **Ячейка из одной точки** — не занятие.
|
||||
- **Время в начале ячейки:**
|
||||
@@ -170,6 +172,7 @@ npm run sync
|
||||
**Занятия в другом городе** (`server/places.js`, парсер 14). Большинство пар — в Новополоцке, но часть факультетов учится в Полоцке, и Word отмечает это по‑разному:
|
||||
- **«Занятия в Полоцке» над первой парой дня** (24‑ИЯ (РА)и‑1…6, 25‑ИЯ (РА)и‑3, 24‑РГФи‑1, 22‑ЛОМК, 25‑ТДи) — не занятие, а заметка дня для столбца: в этот день все пары группы или подгруппы идут в том городе (и аудитории у них полоцкие — Г, Д, Е, Ж). Заметка лежит в разобранном документе в `places` (`{day, date, groups, subgroups, place, text}`), занятиям этого дня проставляется `place`. Если в той же ячейке под заметкой есть пара, это обычное занятие без приставки в названии. Занятие всей группы получает город, когда заметка стоит во всех её подгруппах.
|
||||
- **Город строкой в ячейке занятия** — «Новополоцк», «г.Новополоцк» — или **после аудитории** — «246(Новополоцк)», «121(г.Новополоцк)» — город этого занятия; из названия и аудитории он убирается.
|
||||
- **Заметка посреди дня** (25‑ИТ, пятница: «Занятия в Полоцке» в строке 13:40) действует с этой пары: в разобранном документе у неё `from`, утренние пары города не получают. `/api/timetable` отдаёт `from` в `places`, Android пишет «С 13:40 — занятия в Полоцке».
|
||||
- Пишется и «Занятия в г. Новополоцк», «Занятия проходят в …», «Занятия в Междуречье». Город приводится к именительному падежу («Полоцке» → «Полоцк»). Название предмета вроде «История Полоцка» городом не считается.
|
||||
- В документах «Введение в высшее образование» строка «г. Полоцк, Дом офицеров» даёт занятию город, а место проведения остаётся в тексте.
|
||||
- Какие заметки показать над днём группы, решает общий модуль `shared/places.js`: им пользуются и `/api/timetable`, и PWA. Одинаковая заметка во всех подгруппах сводится к одной для всей группы, а заметка своей подгруппы показывается как заметка всего дня.
|
||||
@@ -249,7 +252,7 @@ DOMAIN=schedule.example.by docker compose --profile https up -d --build
|
||||
|
||||
В папке `android/` — нативный клиент на Kotlin и Jetpack Compose с компонентами Material 3 Expressive (`MaterialExpressiveTheme`, `ModalWideNavigationRail` и `WideNavigationRail`, соединённые `ToggleButton`, `LoadingIndicator`). Базовая тема синяя (тональная схема M3); с «Цветами обоев» — динамическая, а цвета предметов и зелёной недели гармонизируются с ней. Разделы открываются из бокового меню: на телефоне оно выезжает, на планшете в портрете и на телефоне в альбомной ориентации — узкая боковая панель, на планшете в альбомной ориентации меню открыто постоянно. На широком экране разделы делятся на две панели, как в School Planner: «Обзор» — карточки и недельная сетка, «Планы» и «Календарь» — список и выбранный день, «Поиск» — результаты и день преподавателя или аудитории; «Предметы» — сеткой карточек, «Расписание» открывается неделей; карточки лежат на подложке с оттенком, системные панели следуют теме приложения.
|
||||
|
||||
**Складные телефоны.** Приложение следит за сгибом экрана через Jetpack WindowManager (`FoldingFeature`, `ui/Fold.kt`).
|
||||
**Складные телефоны.** Приложение следит за сгибом экрана через Jetpack WindowManager (`FoldingFeature`, `ui/components/Fold.kt`).
|
||||
- **Фолды** (Galaxy Z Fold, Pixel Fold) в разложенном виде — окно через сгиб с короткой стороной от 600dp — получают планшетный макет:
|
||||
- «Обзор», «Планы», «Календарь» и «Поиск» — две панели, как две страницы, по обе стороны вертикального сгиба, и в плоском, и в полураскрытом виде;
|
||||
- «Расписание» открывается неделей на карточке;
|
||||
@@ -267,9 +270,14 @@ DOMAIN=schedule.example.by docker compose --profile https up -d --build
|
||||
- **Сегодня — на текущей паре.** Страница сегодняшнего дня открывается на идущей паре (или следующей), а не на первой утренней.
|
||||
- **Виброотклик:** тик при выборе дня в ленте и при листании дней пальцем, при выборе в кнопках-переключателях. Подтверждение при выполнении задания и отметке посещения, щелчок у переключателей настроек.
|
||||
- **Скелетоны вместо спиннера:** пока грузится первое расписание группы, «Обзор», «Расписание» и «Предметы» показывают серые заготовки в форме своих карточек с мягким бликом. Так же выглядят занятия преподавателя или аудитории в поиске и каталог групп. Для TalkBack это одно «Загрузка». При системной настройке «Убрать анимацию» блик, появление карточек и прочие украшения отключаются.
|
||||
- **Карточка вырастает в подробности** (container transform). Пара открывается из своей карточки: из «Обзора», расписания по дням, недельной сетки, календаря «Планов» и подставки «ноутбука». Рамка плавно растягивается до полного экрана (на планшете — до большой панели по центру), картинка карточки растворяется, подробности проявляются. «Назад», предиктивный жест и касание за панелью сворачивают её обратно в карточку. Так же «+» в «Планах» и календаре раскрывается в лист нового задания; лист можно стянуть вниз за ручку.
|
||||
- **Карточка вырастает в подробности** (container transform). Пара открывается из своей карточки: из «Обзора», расписания по дням, недельной сетки, календаря «Планов» и подставки «ноутбука». Рамка плавно растягивается до полного экрана (на планшете — до большой панели по центру), а содержимое едет вместе с ней, как в M3 (container transform из Material Components): и карточка, и страница подробностей масштабируются по ширине рамки от её левого верхнего угла, так что заголовок и сведения о паре выходят из самой карточки. Страница проявляется поверх карточки за первую четверть пути, углы выпрямляются за три четверти; при закрытии она гаснет поздно, между 60 и 90 % пути, и рамка с карточкой внутри сжимается на место. «Назад», предиктивный жест и касание за панелью сворачивают её обратно в карточку. Так же «+» в «Планах» и календаре раскрывается в лист нового задания; лист можно стянуть вниз за ручку, и он тоже сжимается обратно в «+». Контейнер рисует только содержимое источника (иконку «+», текст карточки), а заливку, углы и тень — свои: у «+» тень плавно уходит при росте и возвращается к его уровню при посадке, так что ни второй формы внутри, ни скачка тени в конце нет.
|
||||
- **Живая текущая пара.** Вокруг идущей пары по часовой стрелке от левого верхнего угла рисуется рамка: какая часть пары прошла. Она подрастает плавно, без скачков. Цифры обратного отсчёта («ещё 44 мин», «Через 15 мин») прокручиваются, как на табло. Линия «сейчас» в недельной сетке ползёт вниз вместе со временем.
|
||||
- **Лента дней.** Выбранный день — один круг под всей лентой: при выборе другого дня он перетекает туда на пружинах, передний край спешит, задний догоняет, и по дороге круг вытягивается в каплю. В «Расписании» точка в круге дня значит «есть пары». Когда пролистываешь на другую неделю, её точки появляются по очереди, с понедельника.
|
||||
- **Долгое нажатие на пару** (в расписании, недельной сетке и «Обзоре») открывает лист действий: «Подробнее» (страница вырастает из той же карточки), «Задание или заметка», «Был / Пропустил», когда пара уже началась, «Поделиться» (предмет, вид, день, время, аудитория, преподаватель) и «Скрыть такие занятия» с «Вернуть». Для TalkBack — действие «Действия» у карточки.
|
||||
- **Между парами** в расписании дня — строка, когда есть что сказать: «Окно 1 ч 35 мин» (от 45 минут, то есть пропущена пара), «Другой корпус: 211А → 254В · перерыв 30 мин» (корпус — буквы после номера аудитории), «Переезд в Полоцк» по городу пары; переезд с перерывом меньше часа выделен красным. Обычные перерывы 10–30 минут и дистанционные пары строки не дают.
|
||||
- **Избранное и недавние в поиске.** Звёздочка у преподавателя или аудитории добавляет их в «Избранное»; до ввода запроса поиск показывает «Избранное» и «Недавние» (последние восемь открытых). Касание открывает сегодняшний день, и у идущей пары — «Идёт · ещё 40 мин» с рамкой прогресса, у ближайшей — «Через 15 мин»: где преподаватель сейчас, видно сразу.
|
||||
- **Свайпы по заданиям** (как в Buckwheat). Вправо — «Выполнено», у выполненного — «Не выполнено». Влево — «Перенести»: задание переезжает к следующей паре того же предмета, общий план — на завтра; внизу «Перенесено на пн, 26 октября, 15:20 · Вернуть». Под строкой открывается прямоугольная полоса с иконкой действия; за порогом цвет действия заливает её кругом от иконки, иконка подпрыгивает, телефон щёлкает. Отпущенная строка всегда пружинит на место. Пока студент ни разу не свайпнул задание, одна строка на экране сама покачивается в стороны и показывает действия под собой; касание её останавливает, первый свайп убирает подсказку насовсем. На «Доске» свайпов нет: там жест листает дни. Для TalkBack те же действия — в меню действий строки.
|
||||
- **Смена темы.** «Светлая», «Тёмная» и «Цвета обоев» в настройках меняют тему кругом, который расходится от нажатой кнопки: снимок старого экрана лежит поверх приложения, и в нём растёт дыра с новой темой (`ui/theme/ThemeReveal.kt`). Значки строки состояния рисует система, они меняются сразу.
|
||||
- **Выполненное задание.** При отметке текст зачёркивается слева направо, строка за строкой, и задание чуть задерживается на месте, прежде чем уйти к выполненным. Когда закрыто последнее открытое, у «Всё сделано» выскакивает галочка.
|
||||
- **Обновление жестом.** Потянуть вниз в «Обзоре» и в расписании: экспрессивный индикатор M3 с меняющейся формой и щелчок отклика, когда тянуть уже достаточно.
|
||||
- **Свежие изменения на виду.** Если университет перезалил документ и пары изменились или добавились, их карточки в расписании дважды вспыхивают цветом (один раз, при первом показе).
|
||||
@@ -286,7 +294,7 @@ DOMAIN=schedule.example.by docker compose --profile https up -d --build
|
||||
- **Группа, которой больше нет.** Если сервер отвечает `group_not_found` или `document_not_found` — группу разделили («24‑ИЯ(РА)» стала «24‑ИЯ (РА)и‑1»…), или документ ушёл из каталога, — плашка с текстом сервера предлагает «Выбрать группу». Сохранённая копия остаётся на экране, а устаревшая группа убирается из недавних.
|
||||
- **Изменения в расписании.** Как в PWA: когда университет перезаливает документ группы, приложение сравнивает новое расписание с копией на устройстве. В «Обзоре» появляется карточка «Расписание обновлено · N изменений» с кнопкой «Показать», а в списке видно, что изменилось, что добавлено и что удалено, например «Аудитория: 374 → 375». Карточка показывается до просмотра или закрытия, последний список остаётся в настройках («Последние изменения»). Сравнение срабатывает и при фоновом обновлении (`SyncWorker`, раз в 6 часов). Если изменения нашлись в фоне, приходит уведомление «Расписание 23-АРХ изменилось» с первыми изменениями, например «Проектирование общественных зданий: аудитория 374 → 375». Уведомление локальное, сервер push для него не нужен. Его можно выключить в настройках («Изменения в расписании», по умолчанию включено), а на Android 13+ нужно разрешение на уведомления. Считается только новая версия того же файла: смена версии парсера или переход на другой документ изменениями не считаются. Сравниваются строки Word по дням недели и разовые занятия со своей датой, а не каждая рассчитанная дата.
|
||||
- Задания и заметки к паре: из карточки занятия — к этой паре или к следующей паре того же предмета. Задания отмечаются выполненными, просроченные подсвечиваются; список — в «Обзоре», по предмету — в «Предметах», точка на блоке в недельной сетке. Хранятся только на устройстве (`notes.json`) и привязаны к группе.
|
||||
- **Фото к заданиям:** в редакторе задания или заметки — «Снять» (приложение камеры) и «Галерея» (системный выбор фото, без доступа к памяти), до 6 фото. Фото уменьшается до 2048 px по длинной стороне, поворачивается по EXIF и хранится в `files/photos/` рядом с заметками. Удаление фото, задания или отмена правки убирает файлы, а при запуске удаляются фото, на которые не ссылается ни одна заметка. В списках у задания миниатюра (с «+N»), на карточке пары — значок фото. Просмотр — на весь экран, с листанием, масштабом щипком и двойным касанием. Задание может быть и одним фото, без текста.
|
||||
- **Фото к заданиям:** в редакторе задания или заметки — «Снять» (приложение камеры) и «Галерея» (системный выбор фото, без доступа к памяти), до 6 фото. Фото уменьшается до 2048 px по длинной стороне, поворачивается по EXIF и хранится в `files/photos/` рядом с заметками. Удаление фото, задания или отмена правки убирает файлы, а при запуске удаляются фото, на которые не ссылается ни одна заметка. В списках у задания миниатюра (с «+N»), на карточке пары — значок фото. Просмотр — на весь экран, с листанием, масштабом щипком и двойным касанием. Он открывается по M3 как container transform для «героического» момента: фото вырастает из миниатюры (в строке задания или в редакторе) из квадратной обрезки в целый снимок, углы выпрямляются, вокруг поднимается чёрный фон, а при закрытии (крестик, «Назад», предиктивный жест) фото сжимается обратно в свою миниатюру; у фото без своей миниатюры — в ту, из которой открыли, и на последнем отрезке перетекает в её снимок. Миниатюра запоминает место на экране, поэтому переход работает и из диалога редактора. Задание может быть и одним фото, без текста.
|
||||
- Посещаемость: «был» или «пропустил» в карточке занятия и одним касанием в «Обзоре» для прошедших сегодня пар; статистика по каждому предмету в «Предметах». Хранится на устройстве (`attendance.json`).
|
||||
- Скрытие занятий: «Скрыть такие занятия» в карточке занятия убирает предмет с этим типом занятия из расписания, обзора, виджетов и напоминаний; вернуть можно в настройках.
|
||||
- Второй виджет «Следующая пара» (4×1) и плитка в шторке быстрых настроек с текущей или следующей парой и аудиторией.
|
||||
@@ -365,6 +373,11 @@ SIMCTL_CHILD_PSU_API_URL=http://localhost:3010/ xcrun simctl launch booted org.x
|
||||
- `server/index.js` — API и выдача собранной PWA.
|
||||
- `server/limits.js` — ограничения импорта: число одновременных разборов и запросов с одного адреса.
|
||||
- `server/errors.js` — коды ошибок API и их HTTP-статусы.
|
||||
- `android/app/src/main/kotlin/org/xtimms/psu/schedule/` — приложение Android:
|
||||
- `data/` — API, модели, настройки, задания и посещаемость; `notify/`, `widget/`, `work/`, `tile/` — уведомления, виджеты, фоновая синхронизация, плитка;
|
||||
- `ui/` — `MainViewModel` и меню; `ui/components/` — общие элементы (заголовки, лента дней, скелетоны, отмена, свайпы, сгиб экрана), `ui/motion/` — переходы и container transform, `ui/theme/` — тема и её смена;
|
||||
- `ui/lesson/` — карточка и страница пары, слоты дня, цвета предметов, отметки сессии, города, посещаемость; `ui/notes/` — задания, заметки и фото;
|
||||
- экраны: `ui/home/` (обзор, изменения, сессия), `ui/schedule/` (дни, неделя, подставка), `ui/plans/` (планы, календарь), `ui/subjects/`, `ui/groups/`, `ui/search/`, `ui/settings/`, `ui/onboarding/`.
|
||||
- `ios/Shared/` — модели, даты в часовом поясе Минска, расчёт пар и общее хранилище; общие для приложения и виджетов.
|
||||
- `ios/PSUSchedule/` — приложение iOS: `AppModel.swift` (состояние и загрузка), `API.swift`, `Reminders.swift`, экраны в `Views/`.
|
||||
- `ios/Widgets/` — виджеты WidgetKit.
|
||||
|
||||
@@ -19,8 +19,8 @@ android {
|
||||
applicationId = "org.xtimms.psu.schedule"
|
||||
minSdk = 26
|
||||
targetSdk = 37
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
versionCode = 2
|
||||
versionName = "0.1.1"
|
||||
buildConfigField("String", "API_URL", "\"${apiUrl.get()}\"")
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,10 +7,10 @@ import androidx.compose.foundation.gestures.Orientation
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import org.xtimms.psu.schedule.ui.UndoArea
|
||||
import org.xtimms.psu.schedule.ui.LocalMorph
|
||||
import org.xtimms.psu.schedule.ui.MorphHost
|
||||
import org.xtimms.psu.schedule.ui.MorphOverlay
|
||||
import org.xtimms.psu.schedule.ui.components.UndoArea
|
||||
import org.xtimms.psu.schedule.ui.motion.LocalMorph
|
||||
import org.xtimms.psu.schedule.ui.motion.MorphHost
|
||||
import org.xtimms.psu.schedule.ui.motion.MorphOverlay
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.rememberTransition
|
||||
import androidx.compose.animation.core.SeekableTransitionState
|
||||
@@ -20,8 +20,8 @@ import androidx.compose.material.icons.outlined.CalendarViewWeek
|
||||
import androidx.compose.material.icons.outlined.Checklist
|
||||
import androidx.compose.material.icons.rounded.CalendarViewWeek
|
||||
import androidx.compose.material.icons.rounded.Checklist
|
||||
import org.xtimms.psu.schedule.ui.CalendarScreen
|
||||
import org.xtimms.psu.schedule.ui.PlansScreen
|
||||
import org.xtimms.psu.schedule.ui.plans.CalendarScreen
|
||||
import org.xtimms.psu.schedule.ui.plans.PlansScreen
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
@@ -39,13 +39,13 @@ import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material.icons.automirrored.rounded.MenuOpen
|
||||
import org.xtimms.psu.schedule.ui.materialFadeThrough
|
||||
import org.xtimms.psu.schedule.ui.Emphasized
|
||||
import org.xtimms.psu.schedule.ui.MotionDuration
|
||||
import org.xtimms.psu.schedule.ui.motion.materialFadeThrough
|
||||
import org.xtimms.psu.schedule.ui.motion.Emphasized
|
||||
import org.xtimms.psu.schedule.ui.motion.MotionDuration
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.animation.core.tween
|
||||
import org.xtimms.psu.schedule.ui.materialSharedAxisX
|
||||
import org.xtimms.psu.schedule.ui.rememberSlideDistance
|
||||
import org.xtimms.psu.schedule.ui.motion.materialSharedAxisX
|
||||
import org.xtimms.psu.schedule.ui.motion.rememberSlideDistance
|
||||
import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
@@ -60,10 +60,10 @@ import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import org.xtimms.psu.schedule.ui.LocalHinge
|
||||
import org.xtimms.psu.schedule.ui.EmphasizedDecelerate
|
||||
import org.xtimms.psu.schedule.ui.EmphasizedAccelerate
|
||||
import org.xtimms.psu.schedule.ui.LocalFoldTablet
|
||||
import org.xtimms.psu.schedule.ui.components.LocalHinge
|
||||
import org.xtimms.psu.schedule.ui.motion.EmphasizedDecelerate
|
||||
import org.xtimms.psu.schedule.ui.motion.EmphasizedAccelerate
|
||||
import org.xtimms.psu.schedule.ui.components.LocalFoldTablet
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -115,27 +115,31 @@ import kotlinx.coroutines.launch
|
||||
import org.xtimms.psu.schedule.data.ThemeMode
|
||||
import org.xtimms.psu.schedule.data.today
|
||||
import org.xtimms.psu.schedule.ui.AppNav
|
||||
import org.xtimms.psu.schedule.ui.GroupsScreen
|
||||
import org.xtimms.psu.schedule.ui.HomeScreen
|
||||
import org.xtimms.psu.schedule.ui.Onboarding
|
||||
import org.xtimms.psu.schedule.ui.LocalDisplay
|
||||
import org.xtimms.psu.schedule.ui.LocalMarks
|
||||
import org.xtimms.psu.schedule.ui.groups.GroupsScreen
|
||||
import org.xtimms.psu.schedule.ui.home.HomeScreen
|
||||
import org.xtimms.psu.schedule.ui.onboarding.Onboarding
|
||||
import org.xtimms.psu.schedule.ui.lesson.LocalDisplay
|
||||
import org.xtimms.psu.schedule.ui.lesson.LocalMarks
|
||||
import org.xtimms.psu.schedule.ui.LocalMenuProgress
|
||||
import org.xtimms.psu.schedule.ui.theme.card
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import org.xtimms.psu.schedule.ui.LocalNotes
|
||||
import org.xtimms.psu.schedule.ui.LocalSubjectSlots
|
||||
import org.xtimms.psu.schedule.ui.notes.LocalNotes
|
||||
import org.xtimms.psu.schedule.ui.lesson.LocalSubjectSlots
|
||||
import org.xtimms.psu.schedule.data.Display
|
||||
import org.xtimms.psu.schedule.ui.MainViewModel
|
||||
import org.xtimms.psu.schedule.ui.ScheduleScreen
|
||||
import org.xtimms.psu.schedule.ui.SearchScreen
|
||||
import org.xtimms.psu.schedule.ui.SettingsScreen
|
||||
import org.xtimms.psu.schedule.ui.SubjectsScreen
|
||||
import org.xtimms.psu.schedule.ui.subjectSlots
|
||||
import org.xtimms.psu.schedule.ui.schedule.ScheduleScreen
|
||||
import org.xtimms.psu.schedule.ui.search.SearchScreen
|
||||
import org.xtimms.psu.schedule.ui.settings.SettingsScreen
|
||||
import org.xtimms.psu.schedule.ui.subjects.SubjectsScreen
|
||||
import org.xtimms.psu.schedule.ui.lesson.subjectSlots
|
||||
import org.xtimms.psu.schedule.ui.theme.ScheduleTheme
|
||||
import org.xtimms.psu.schedule.ui.theme.ThemeRevealHost
|
||||
import org.xtimms.psu.schedule.ui.lesson.LessonActionsHost
|
||||
import org.xtimms.psu.schedule.ui.theme.isDark
|
||||
import org.xtimms.psu.schedule.ui.theme.page
|
||||
import java.time.LocalDate
|
||||
import org.xtimms.psu.schedule.ui.components.LocalSwipeHint
|
||||
import org.xtimms.psu.schedule.ui.components.SwipeHint
|
||||
|
||||
private enum class Tab(val label: String, val icon: ImageVector, val selectedIcon: ImageVector) {
|
||||
Home("Обзор", Icons.Outlined.Home, Icons.Rounded.Home),
|
||||
@@ -193,9 +197,11 @@ class MainActivity : ComponentActivity() {
|
||||
onDispose {}
|
||||
}
|
||||
// The fold of a foldable phone, for screens that split their panes around it.
|
||||
val hinge = org.xtimms.psu.schedule.ui.rememberHinge(this)
|
||||
val hinge = org.xtimms.psu.schedule.ui.components.rememberHinge(this)
|
||||
ScheduleTheme(mode = s?.theme ?: ThemeMode.System, dynamicColor = s?.dynamicColor ?: false) {
|
||||
androidx.compose.runtime.CompositionLocalProvider(org.xtimms.psu.schedule.ui.LocalHinge provides hinge) {
|
||||
// Light and dark swap with a circle spreading from the finger (ThemeReveal.kt).
|
||||
ThemeRevealHost(key = dark to (s?.dynamicColor ?: false)) {
|
||||
androidx.compose.runtime.CompositionLocalProvider(org.xtimms.psu.schedule.ui.components.LocalHinge provides hinge) {
|
||||
Surface(color = MaterialTheme.colorScheme.page, modifier = Modifier.fillMaxSize()) {
|
||||
// Wait for the stored settings so a returning student never sees the onboarding.
|
||||
if (s != null) {
|
||||
@@ -207,11 +213,18 @@ class MainActivity : ComponentActivity() {
|
||||
label = "onboarding",
|
||||
) { first ->
|
||||
if (first) Onboarding(vm, onDone = { onboarding = false })
|
||||
else App(vm, firstRun = s.current == null, launch = launch.value, onLaunched = { launch.value = null })
|
||||
else {
|
||||
// Until the first swipe, a task row shows how it is done (SwipeActions.kt).
|
||||
val hint = remember(s.swipeTried) { SwipeHint(!s.swipeTried, vm::swipeTried) }
|
||||
androidx.compose.runtime.CompositionLocalProvider(LocalSwipeHint provides hint) {
|
||||
App(vm, firstRun = s.current == null, launch = launch.value, onLaunched = { launch.value = null })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -281,6 +294,8 @@ private fun App(vm: MainViewModel, firstRun: Boolean, launch: Launch?, onLaunche
|
||||
val morph = remember { MorphHost() }
|
||||
UndoArea(Modifier.fillMaxSize()) {
|
||||
androidx.compose.runtime.CompositionLocalProvider(LocalMorph provides morph) {
|
||||
// A long press on a lesson anywhere opens its actions (LessonActions.kt).
|
||||
LessonActionsHost(vm) {
|
||||
BoxWithConstraints(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.card)) {
|
||||
val wide = maxWidth >= MEDIUM_WIDTH
|
||||
// An open book-style foldable gets the tablet layout; a flip phone keeps the phone one.
|
||||
@@ -535,6 +550,7 @@ private fun App(vm: MainViewModel, firstRun: Boolean, launch: Launch?, onLaunche
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// A card's details and the "+" sheet open over everything, growing from what was tapped.
|
||||
MorphOverlay(morph)
|
||||
}
|
||||
|
||||
@@ -60,7 +60,16 @@ data class WeeklyDay(val day: Int, val lessons: List<Lesson> = emptyList(), val
|
||||
* when `subgroup` is set) are in another town (server/timetable.js placesOn).
|
||||
*/
|
||||
@Serializable
|
||||
data class DayPlace(val place: String, val text: String, val subgroup: String? = null)
|
||||
data class DayPlace(
|
||||
val place: String,
|
||||
val text: String,
|
||||
val subgroup: String? = null,
|
||||
/** "13:40": the note stands in the middle of the day and holds from that lesson on. */
|
||||
val from: String? = null,
|
||||
) {
|
||||
/** "С 13:40 — занятия в Полоцке", or the note as it is when it holds all day. */
|
||||
val label: String get() = from?.let { "С $it — " + text.replaceFirstChar { c -> c.lowercase() } } ?: text
|
||||
}
|
||||
|
||||
/** The notes for the student's subgroup; a note for their own subgroup reads as the whole day's. */
|
||||
fun List<DayPlace>.forSubgroup(subgroup: String?): List<DayPlace> =
|
||||
|
||||
@@ -56,6 +56,12 @@ data class UserSettings(
|
||||
val roomTime: Int = 7 * 60,
|
||||
/** The lesson under way as an ongoing notification (a Live Update on Android 16). */
|
||||
val liveLesson: Boolean = false,
|
||||
/** The student has swiped a task once: the rows stop showing how. */
|
||||
val swipeTried: Boolean = false,
|
||||
/** Teachers and rooms starred in the search, as "teacher:Ковшик О.С." or "room:206Г". */
|
||||
val searchFavorites: List<String> = emptyList(),
|
||||
/** The teachers and rooms opened in the search lately, newest first. */
|
||||
val searchRecent: List<String> = emptyList(),
|
||||
val recent: List<SavedGroup> = emptyList(),
|
||||
val display: Display = Display(),
|
||||
/**
|
||||
@@ -90,6 +96,9 @@ class Settings(private val context: Context) {
|
||||
val roomAlerts = booleanPreferencesKey("room_alerts")
|
||||
val roomTime = intPreferencesKey("room_time")
|
||||
val liveLesson = booleanPreferencesKey("live_lesson")
|
||||
val swipeTried = booleanPreferencesKey("swipe_tried")
|
||||
val searchFavorites = stringPreferencesKey("search_favorites")
|
||||
val searchRecent = stringPreferencesKey("search_recent")
|
||||
val recent = stringPreferencesKey("recent")
|
||||
val colors = booleanPreferencesKey("show_colors")
|
||||
val teachers = booleanPreferencesKey("show_teachers")
|
||||
@@ -103,6 +112,9 @@ class Settings(private val context: Context) {
|
||||
val plansDone = booleanPreferencesKey("plans_done")
|
||||
}
|
||||
|
||||
private fun Preferences.list(key: Preferences.Key<String>): List<String> =
|
||||
this[key]?.let { runCatching { json.decodeFromString<List<String>>(it) }.getOrNull() }.orEmpty()
|
||||
|
||||
private fun Preferences.choiceMap(): Map<String, Map<String, String>> =
|
||||
this[Keys.choices]?.let { runCatching { json.decodeFromString<Map<String, Map<String, String>>>(it) }.getOrNull() }.orEmpty()
|
||||
|
||||
@@ -143,6 +155,9 @@ class Settings(private val context: Context) {
|
||||
roomAlerts = this[Keys.roomAlerts] ?: true,
|
||||
roomTime = this[Keys.roomTime] ?: 7 * 60,
|
||||
liveLesson = this[Keys.liveLesson] ?: false,
|
||||
swipeTried = this[Keys.swipeTried] ?: false,
|
||||
searchFavorites = list(Keys.searchFavorites),
|
||||
searchRecent = list(Keys.searchRecent),
|
||||
recent = this[Keys.recent]?.let {
|
||||
runCatching { json.decodeFromString<List<SavedGroup>>(it) }.getOrNull()
|
||||
}.orEmpty(),
|
||||
@@ -229,6 +244,18 @@ class Settings(private val context: Context) {
|
||||
suspend fun setRoomAlerts(on: Boolean) = context.store.edit { it[Keys.roomAlerts] = on }
|
||||
suspend fun setRoomTime(minutes: Int) = context.store.edit { it[Keys.roomTime] = minutes }
|
||||
suspend fun setLiveLesson(on: Boolean) = context.store.edit { it[Keys.liveLesson] = on }
|
||||
suspend fun swipeTried() = context.store.edit { it[Keys.swipeTried] = true }
|
||||
|
||||
/** Stars a teacher or a room of the search, or takes the star back. */
|
||||
suspend fun toggleSearchFavorite(target: String) = context.store.edit { prefs ->
|
||||
val list = prefs.list(Keys.searchFavorites)
|
||||
prefs[Keys.searchFavorites] = json.encodeToString(if (target in list) list - target else list + target)
|
||||
}
|
||||
|
||||
/** Remembers a teacher or a room opened in the search; the latest eight stay. */
|
||||
suspend fun rememberSearch(target: String) = context.store.edit { prefs ->
|
||||
prefs[Keys.searchRecent] = json.encodeToString((listOf(target) + prefs.list(Keys.searchRecent).filter { it != target }).take(8))
|
||||
}
|
||||
|
||||
/** Hides or shows again a recurring lesson of the open group. */
|
||||
suspend fun setHidden(key: String, hidden: Boolean) = context.store.edit { prefs ->
|
||||
|
||||
@@ -13,8 +13,8 @@ import org.xtimms.psu.schedule.data.ScheduleChanges
|
||||
import org.xtimms.psu.schedule.data.dayName
|
||||
import org.xtimms.psu.schedule.data.longLabel
|
||||
import org.xtimms.psu.schedule.data.parseDate
|
||||
import org.xtimms.psu.schedule.ui.changeDate
|
||||
import org.xtimms.psu.schedule.ui.changesLabel
|
||||
import org.xtimms.psu.schedule.ui.home.changeDate
|
||||
import org.xtimms.psu.schedule.ui.home.changesLabel
|
||||
|
||||
/**
|
||||
* A notification when the background sync finds that the university republished the group's
|
||||
|
||||
@@ -74,7 +74,8 @@ object DayAlerts {
|
||||
val at = date.minusDays(1).atTime(LocalTime.ofSecondOfDay(settings.travelTime * 60L)).atZone(MINSK).toInstant()
|
||||
if (at.isAfter(now)) {
|
||||
val note = places.first()
|
||||
val title = "Завтра " + note.text.replaceFirstChar { it.lowercase() } + (note.subgroup?.let { " · ${it.lowercase()}" } ?: "")
|
||||
// "Завтра с 13:40 — занятия в Полоцке" when the town is only for the afternoon.
|
||||
val title = "Завтра " + note.label.replaceFirstChar { it.lowercase() } + (note.subgroup?.let { " · ${it.lowercase()}" } ?: "")
|
||||
val text = "Первая пара в ${first.start} — ${first.subject}" +
|
||||
(withPlace(rooms(first), first.place)?.let { ", $it" } ?: "") +
|
||||
". Всего ${lessons.distinctBy { it.start }.size} ${plural(lessons.distinctBy { it.start }.size, "пара", "пары", "пар")}."
|
||||
|
||||
@@ -24,7 +24,7 @@ import org.xtimms.psu.schedule.data.nowDateTime
|
||||
import org.xtimms.psu.schedule.data.parseTime
|
||||
import org.xtimms.psu.schedule.data.today
|
||||
import org.xtimms.psu.schedule.data.withPlace
|
||||
import org.xtimms.psu.schedule.ui.lessonEnd
|
||||
import org.xtimms.psu.schedule.ui.lesson.lessonEnd
|
||||
import java.time.Duration
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@@ -23,6 +23,11 @@ import org.xtimms.psu.schedule.data.ThemeMode
|
||||
import org.xtimms.psu.schedule.data.Timetable
|
||||
import org.xtimms.psu.schedule.data.UserSettings
|
||||
import org.xtimms.psu.schedule.work.SyncWorker
|
||||
import org.xtimms.psu.schedule.ui.components.UndoHost
|
||||
import org.xtimms.psu.schedule.data.parseDate
|
||||
import org.xtimms.psu.schedule.ui.notes.dueLabel
|
||||
import org.xtimms.psu.schedule.ui.notes.general
|
||||
import org.xtimms.psu.schedule.ui.notes.nextOccurrence
|
||||
|
||||
data class Status(val loading: Boolean = false, val message: String? = null, val offline: Boolean = false, val code: String? = null) {
|
||||
/**
|
||||
@@ -89,6 +94,26 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
fun toggleNote(id: String) = viewModelScope.launch { app.notes.toggle(id) }
|
||||
|
||||
/**
|
||||
* Moves a task or a note to the next lesson of its subject, a plan without one to the next
|
||||
* day, and offers the old place back in [undo]. With no such lesson loaded it says so.
|
||||
*/
|
||||
fun postponeNote(note: Note, undo: UndoHost?) = viewModelScope.launch {
|
||||
val from = parseDate(note.date)
|
||||
val moved = if (note.general) note.copy(date = from.plusDays(1).toString())
|
||||
else {
|
||||
val t = timetable.value ?: return@launch
|
||||
val next = nextOccurrence(t, note.subject, from, note.start, settings.value?.subgroup)
|
||||
?: return@launch undo?.say("Следующей пары «${note.subject}» в расписании нет") ?: Unit
|
||||
note.copy(date = next.first.toString(), start = next.second.start)
|
||||
}
|
||||
app.notes.save(moved)
|
||||
// "на пн, 26 октября, 15:20": the time too, as the next lesson may be later the same day.
|
||||
val day = moved.dueLabel().removePrefix("к ")
|
||||
val at = if (moved.start.isNotBlank() && moved.start !in day) "$day, ${moved.start}" else day
|
||||
undo?.offer("Перенесено на $at", onUndo = { viewModelScope.launch { app.notes.save(note) } })
|
||||
}
|
||||
|
||||
/** The key new notes are filed under, or null before a group is chosen. */
|
||||
fun currentGroupKey(): String? = settings.value?.current?.let(::groupKey)
|
||||
|
||||
@@ -170,6 +195,11 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
SyncWorker.afterChange(getApplication())
|
||||
}
|
||||
|
||||
fun toggleSearchFavorite(target: String) = viewModelScope.launch { app.settings.toggleSearchFavorite(target) }
|
||||
fun rememberSearch(target: String) = viewModelScope.launch { app.settings.rememberSearch(target) }
|
||||
|
||||
fun swipeTried() = viewModelScope.launch { if (settings.value?.swipeTried == false) app.settings.swipeTried() }
|
||||
|
||||
fun setTheme(mode: ThemeMode) = viewModelScope.launch { app.settings.setTheme(mode) }
|
||||
fun setDynamicColor(on: Boolean) = viewModelScope.launch { app.settings.setDynamicColor(on) }
|
||||
fun setDisplay(display: Display) = viewModelScope.launch { app.settings.setDisplay(display) }
|
||||
|
||||
@@ -1,274 +0,0 @@
|
||||
package org.xtimms.psu.schedule.ui
|
||||
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import android.util.LruCache
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.PickVisualMediaRequest
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.gestures.calculatePan
|
||||
import androidx.compose.foundation.gestures.calculateZoom
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Close
|
||||
import androidx.compose.material.icons.rounded.PhotoCamera
|
||||
import androidx.compose.material.icons.rounded.PhotoLibrary
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.FilledIconButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.input.pointer.positionChanged
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.core.content.FileProvider
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.xtimms.psu.schedule.app
|
||||
import java.io.File
|
||||
|
||||
// Photos of the board or the assignment, pinned to a task or a note (NotesStore keeps the files).
|
||||
|
||||
/** Decoded thumbnails, so lists scrolling past the same photos do not decode them again. */
|
||||
private val thumbnails = object : LruCache<String, ImageBitmap>(24 * 1024 * 1024) {
|
||||
override fun sizeOf(key: String, value: ImageBitmap) = value.width * value.height * 4
|
||||
}
|
||||
|
||||
/** A photo decoded no larger than [max] pixels on its long side; null while it loads. */
|
||||
@Composable
|
||||
private fun rememberPhoto(file: File, max: Int): ImageBitmap? {
|
||||
val key = "${file.name}@$max"
|
||||
return produceState(thumbnails.get(key), key) {
|
||||
if (value != null) return@produceState
|
||||
value = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeFile(file.path, bounds)
|
||||
var sample = 1
|
||||
while (maxOf(bounds.outWidth, bounds.outHeight) / (sample * 2) >= max) sample *= 2
|
||||
BitmapFactory.decodeFile(file.path, BitmapFactory.Options().apply { inSampleSize = sample })?.asImageBitmap()
|
||||
}.getOrNull()
|
||||
}?.also { thumbnails.put(key, it) }
|
||||
}.value
|
||||
}
|
||||
|
||||
/** A square thumbnail; [onClick] opens the viewer. */
|
||||
@Composable
|
||||
fun PhotoThumb(name: String, size: Dp, onClick: (() -> Unit)?, modifier: Modifier = Modifier) {
|
||||
val store = LocalContext.current.app.notes
|
||||
val px = with(LocalDensity.current) { size.roundToPx() } * 2
|
||||
val image = rememberPhoto(store.photo(name), px)
|
||||
Box(
|
||||
modifier
|
||||
.size(size)
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
|
||||
.then(if (onClick != null) Modifier.clickable(onClickLabel = "Открыть фото", onClick = onClick) else Modifier),
|
||||
) {
|
||||
image?.let { Image(it, contentDescription = "Фото", contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize()) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The editor's photos: thumbnails with a remove button, then "Снять" (the camera app) and
|
||||
* "Галерея" (the system photo picker, no storage permission needed).
|
||||
*/
|
||||
@Composable
|
||||
fun NotePhotosEditor(photos: List<String>, onAdd: (String) -> Unit, onRemove: (String) -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val store = context.app.notes
|
||||
val scope = rememberCoroutineScope()
|
||||
var loading by remember { mutableIntStateOf(0) }
|
||||
var viewing by remember { mutableStateOf<Int?>(null) }
|
||||
fun import(uris: List<Uri>) {
|
||||
uris.forEach { uri ->
|
||||
loading++
|
||||
scope.launch {
|
||||
store.importPhoto(uri)?.let(onAdd)
|
||||
loading--
|
||||
}
|
||||
}
|
||||
}
|
||||
// The camera app writes the full-size shot here; the store keeps a smaller copy.
|
||||
val shot = remember { File(context.cacheDir, "camera/shot.jpg") }
|
||||
val camera = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { taken ->
|
||||
if (taken) {
|
||||
loading++
|
||||
scope.launch {
|
||||
store.importPhoto(Uri.fromFile(shot))?.let(onAdd)
|
||||
shot.delete()
|
||||
loading--
|
||||
}
|
||||
}
|
||||
}
|
||||
val gallery = rememberLauncherForActivityResult(ActivityResultContracts.PickMultipleVisualMedia(MAX_PHOTOS)) { import(it) }
|
||||
val room = MAX_PHOTOS - photos.size
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
if (photos.isNotEmpty() || loading > 0) Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
||||
) {
|
||||
photos.forEachIndexed { index, name ->
|
||||
Box {
|
||||
PhotoThumb(name, 72.dp, onClick = { viewing = index })
|
||||
FilledIconButton(
|
||||
onClick = { onRemove(name) },
|
||||
colors = IconButtonDefaults.filledIconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.inverseSurface,
|
||||
contentColor = MaterialTheme.colorScheme.inverseOnSurface,
|
||||
),
|
||||
modifier = Modifier.align(Alignment.TopEnd).padding(4.dp).size(24.dp),
|
||||
) { Icon(Icons.Rounded.Close, contentDescription = "Убрать фото", modifier = Modifier.size(16.dp)) }
|
||||
}
|
||||
}
|
||||
repeat(loading) {
|
||||
Box(
|
||||
Modifier.size(72.dp).clip(MaterialTheme.shapes.medium).background(MaterialTheme.colorScheme.surfaceContainerHighest),
|
||||
contentAlignment = Alignment.Center,
|
||||
) { CircularProgressIndicator(Modifier.size(24.dp), strokeWidth = 2.dp) }
|
||||
}
|
||||
}
|
||||
if (room > 0) Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
shot.parentFile?.mkdirs()
|
||||
val uri = FileProvider.getUriForFile(context, context.packageName + ".photos", shot)
|
||||
// A phone without a camera app: the gallery is still there.
|
||||
runCatching { camera.launch(uri) }
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Icon(Icons.Rounded.PhotoCamera, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Снять", maxLines = 1)
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { gallery.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) },
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Icon(Icons.Rounded.PhotoLibrary, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Галерея", maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
viewing?.let { PhotoViewer(photos, it) { viewing = null } }
|
||||
}
|
||||
|
||||
/** Photos full screen, swiped sideways; pinch or double-tap to zoom into the board. */
|
||||
@Composable
|
||||
fun PhotoViewer(photos: List<String>, start: Int, onDismiss: () -> Unit) {
|
||||
if (photos.isEmpty()) return
|
||||
val store = LocalContext.current.app.notes
|
||||
Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) {
|
||||
val pager = rememberPagerState(initialPage = start.coerceIn(0, photos.lastIndex)) { photos.size }
|
||||
Box(Modifier.fillMaxSize().background(Color.Black)) {
|
||||
HorizontalPager(state = pager, modifier = Modifier.fillMaxSize()) { page ->
|
||||
var scale by remember { mutableFloatStateOf(1f) }
|
||||
var offset by remember { mutableStateOf(Offset.Zero) }
|
||||
val image = rememberPhoto(store.photo(photos[page]), 2048)
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(onDoubleTap = {
|
||||
if (scale > 1f) { scale = 1f; offset = Offset.Zero } else scale = 2.5f
|
||||
})
|
||||
}
|
||||
.pointerInput(Unit) {
|
||||
// Two fingers zoom; one finger pans only while zoomed in, so at 1x the
|
||||
// pager still takes the swipe to the next photo.
|
||||
awaitEachGesture {
|
||||
awaitFirstDown(requireUnconsumed = false)
|
||||
do {
|
||||
val event = awaitPointerEvent()
|
||||
if (event.changes.size > 1 || scale > 1f) {
|
||||
scale = (scale * event.calculateZoom()).coerceIn(1f, 5f)
|
||||
offset = if (scale > 1f) offset + event.calculatePan() else Offset.Zero
|
||||
event.changes.forEach { if (it.positionChanged()) it.consume() }
|
||||
}
|
||||
} while (event.changes.any { it.pressed })
|
||||
}
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (image == null) CircularProgressIndicator(color = Color.White)
|
||||
else Image(
|
||||
image,
|
||||
contentDescription = "Фото ${page + 1} из ${photos.size}",
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier.fillMaxSize().graphicsLayer {
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
translationX = offset.x
|
||||
translationY = offset.y
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().statusBarsPadding().padding(8.dp),
|
||||
) {
|
||||
IconButton(onClick = onDismiss) { Icon(Icons.Rounded.Close, contentDescription = "Закрыть", tint = Color.White) }
|
||||
Spacer(Modifier.weight(1f))
|
||||
if (photos.size > 1) Surface(shape = CircleShape, color = Color.Black.copy(alpha = 0.5f), contentColor = Color.White) {
|
||||
Text("${pager.currentPage + 1} / ${photos.size}", style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Up to this many photos per task or note. */
|
||||
const val MAX_PHOTOS = 6
|
||||
@@ -1,6 +1,6 @@
|
||||
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||
|
||||
package org.xtimms.psu.schedule.ui
|
||||
package org.xtimms.psu.schedule.ui.components
|
||||
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
@@ -116,6 +116,13 @@ import org.xtimms.psu.schedule.data.today
|
||||
import org.xtimms.psu.schedule.data.weekSpan
|
||||
import org.xtimms.psu.schedule.ui.theme.WeekColors
|
||||
import java.time.LocalDate
|
||||
import org.xtimms.psu.schedule.ui.lesson.SubjectColor
|
||||
import org.xtimms.psu.schedule.ui.lesson.luminance
|
||||
import org.xtimms.psu.schedule.ui.motion.rememberReducedMotion
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
|
||||
/** A tablet, by its smallest side: its app bars stay calm, as School Planner's do. */
|
||||
@Composable
|
||||
@@ -176,7 +183,11 @@ fun Heading(
|
||||
Text(text, style = style, modifier = modifier.semantics { heading() })
|
||||
}
|
||||
|
||||
/** M3 Expressive connected button group: one choice out of a few. */
|
||||
/**
|
||||
* M3 Expressive connected button group: one choice out of a few. The chosen one gets a check
|
||||
* before its label when both fit; a long label ("Светлая" in a third of a phone) keeps whole
|
||||
* and the fill alone marks it.
|
||||
*/
|
||||
@Composable
|
||||
fun <T> ConnectedChoice(
|
||||
options: List<Pair<T, String>>,
|
||||
@@ -187,42 +198,56 @@ fun <T> ConnectedChoice(
|
||||
mark: Boolean = true,
|
||||
) {
|
||||
val haptic = LocalHapticFeedback.current
|
||||
Row(
|
||||
modifier.selectableGroup(),
|
||||
horizontalArrangement = Arrangement.spacedBy(ButtonGroupDefaults.ConnectedSpaceBetween),
|
||||
) {
|
||||
options.forEachIndexed { index, (value, label) ->
|
||||
val checked = value == selected
|
||||
ToggleButton(
|
||||
checked = checked,
|
||||
onCheckedChange = {
|
||||
if (!checked) haptic.performHapticFeedback(HapticFeedbackType.SegmentTick)
|
||||
onSelect(value)
|
||||
},
|
||||
modifier = Modifier.weight(1f).semantics { role = Role.RadioButton },
|
||||
// Unselected segments need their own tonal fill to read as one group.
|
||||
colors = ToggleButtonDefaults.colors(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
checkedContainerColor = MaterialTheme.colorScheme.primary,
|
||||
checkedContentColor = MaterialTheme.colorScheme.onPrimary,
|
||||
),
|
||||
shapes = when (index) {
|
||||
0 -> ButtonGroupDefaults.connectedLeadingButtonShapes()
|
||||
options.lastIndex -> ButtonGroupDefaults.connectedTrailingButtonShapes()
|
||||
else -> ButtonGroupDefaults.connectedMiddleButtonShapes()
|
||||
},
|
||||
) {
|
||||
if (checked && mark) {
|
||||
Icon(Icons.Rounded.Check, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
val measurer = rememberTextMeasurer()
|
||||
val style = MaterialTheme.typography.labelLarge
|
||||
val density = LocalDensity.current
|
||||
BoxWithConstraints(modifier) {
|
||||
val gap = ButtonGroupDefaults.ConnectedSpaceBetween
|
||||
val segment = (maxWidth - gap * (options.size - 1)) / options.size
|
||||
Row(
|
||||
Modifier.fillMaxWidth().selectableGroup(),
|
||||
horizontalArrangement = Arrangement.spacedBy(gap),
|
||||
) {
|
||||
options.forEachIndexed { index, (value, label) ->
|
||||
val checked = value == selected
|
||||
val text = with(density) { measurer.measure(label, style, maxLines = 1).size.width.toDp() }
|
||||
val check = checked && mark && text + CHECK + PADDING * 2 <= segment
|
||||
ToggleButton(
|
||||
checked = checked,
|
||||
onCheckedChange = {
|
||||
if (!checked) haptic.performHapticFeedback(HapticFeedbackType.SegmentTick)
|
||||
onSelect(value)
|
||||
},
|
||||
modifier = Modifier.weight(1f).semantics { role = Role.RadioButton },
|
||||
// Unselected segments need their own tonal fill to read as one group.
|
||||
colors = ToggleButtonDefaults.colors(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
checkedContainerColor = MaterialTheme.colorScheme.primary,
|
||||
checkedContentColor = MaterialTheme.colorScheme.onPrimary,
|
||||
),
|
||||
shapes = when (index) {
|
||||
0 -> ButtonGroupDefaults.connectedLeadingButtonShapes()
|
||||
options.lastIndex -> ButtonGroupDefaults.connectedTrailingButtonShapes()
|
||||
else -> ButtonGroupDefaults.connectedMiddleButtonShapes()
|
||||
},
|
||||
contentPadding = PaddingValues(horizontal = PADDING),
|
||||
) {
|
||||
if (check) {
|
||||
Icon(Icons.Rounded.Check, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
}
|
||||
Text(label, style = style, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
Text(label, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A connected button's side padding, and its check with the gap after it. */
|
||||
private val PADDING = 12.dp
|
||||
private val CHECK = 24.dp
|
||||
|
||||
/** M3 search input field on a tonal container; reports every edit through [onQueryChange]. */
|
||||
@Composable
|
||||
fun SearchField(query: String, onQueryChange: (String) -> Unit, placeholder: String, modifier: Modifier = Modifier) {
|
||||
@@ -1,4 +1,4 @@
|
||||
package org.xtimms.psu.schedule.ui
|
||||
package org.xtimms.psu.schedule.ui.components
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.Animatable
|
||||
@@ -33,6 +33,8 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import org.xtimms.psu.schedule.ui.motion.EmphasizedDecelerate
|
||||
import org.xtimms.psu.schedule.ui.motion.rememberReducedMotion
|
||||
|
||||
// Finishing a task: its text is struck through from left to right, line after line, and once the
|
||||
// last open task of the plans is done a small check pops up by "Всё сделано".
|
||||
@@ -1,4 +1,4 @@
|
||||
package org.xtimms.psu.schedule.ui
|
||||
package org.xtimms.psu.schedule.ui.components
|
||||
|
||||
import android.app.Activity
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
@@ -0,0 +1,140 @@
|
||||
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||
|
||||
package org.xtimms.psu.schedule.ui.components
|
||||
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.MaterialShapes
|
||||
import androidx.compose.material3.toShape
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.CloudOff
|
||||
import androidx.compose.material.icons.rounded.ErrorOutline
|
||||
import androidx.compose.material.icons.rounded.EventBusy
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun Banner(
|
||||
text: String,
|
||||
offline: Boolean,
|
||||
onClose: (() -> Unit)?,
|
||||
onRetry: (() -> Unit)? = null,
|
||||
/** A way out the message asks for, such as "Выбрать группу". */
|
||||
action: Pair<String, () -> Unit>? = null,
|
||||
) {
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = if (offline) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.errorContainer,
|
||||
contentColor = if (offline) MaterialTheme.colorScheme.onSecondaryContainer else MaterialTheme.colorScheme.onErrorContainer,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
) {
|
||||
val buttons: @Composable () -> Unit = {
|
||||
if (onRetry != null) TextButton(onClick = onRetry) { Text("Повторить") }
|
||||
action?.let { (label, onClick) -> TextButton(onClick = onClick) { Text(label) } }
|
||||
if (onClose != null) TextButton(onClick = onClose) { Text("OK") }
|
||||
}
|
||||
// With an action the message needs the whole width; the buttons go under it.
|
||||
if (action != null) Column(Modifier.padding(start = 16.dp, end = 4.dp, top = 12.dp, bottom = 4.dp)) {
|
||||
Row {
|
||||
Icon(Icons.Rounded.ErrorOutline.takeIf { !offline } ?: Icons.Rounded.CloudOff, contentDescription = null)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text(text, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f).padding(end = 12.dp))
|
||||
}
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { buttons() }
|
||||
} else Row(Modifier.padding(start = 16.dp, end = 4.dp, top = 8.dp, bottom = 8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(if (offline) Icons.Rounded.CloudOff else Icons.Rounded.ErrorOutline, contentDescription = null)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text(text, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f).padding(vertical = 8.dp))
|
||||
buttons()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Centres an empty state in the space it is given — a phone screen or a tablet's pane —
|
||||
* and scrolls when that space is too low, so the illustration is never squeezed.
|
||||
*/
|
||||
@Composable
|
||||
fun CenteredEmpty(
|
||||
modifier: Modifier = Modifier,
|
||||
/** Room kept clear inside the scroll (under a FAB): content scrolls past it, never gets cut. */
|
||||
bottomInset: Dp = 0.dp,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
BoxWithConstraints(modifier.fillMaxSize()) {
|
||||
val height = maxHeight
|
||||
Column(
|
||||
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).heightIn(min = height).padding(bottom = bottomInset),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) { content() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty state in the RideBus style: an icon on a seven-sided cookie shape, a title,
|
||||
* a calm explanation and an optional tonal action.
|
||||
*/
|
||||
@Composable
|
||||
fun Empty(
|
||||
title: String,
|
||||
text: String,
|
||||
icon: ImageVector = Icons.Rounded.EventBusy,
|
||||
action: Pair<String, () -> Unit>? = null,
|
||||
) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth().padding(vertical = 48.dp, horizontal = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.size(112.dp)
|
||||
.background(MaterialTheme.colorScheme.primaryContainer, MaterialShapes.Cookie7Sided.toShape()),
|
||||
) {
|
||||
Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.onPrimaryContainer, modifier = Modifier.size(48.dp))
|
||||
}
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Text(title, style = MaterialTheme.typography.titleLargeEmphasized, textAlign = TextAlign.Center)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
text,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
action?.let { (label, onClick) ->
|
||||
Spacer(Modifier.height(20.dp))
|
||||
FilledTonalButton(onClick = onClick) { Text(label) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package org.xtimms.psu.schedule.ui
|
||||
package org.xtimms.psu.schedule.ui.components
|
||||
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
@@ -37,6 +37,7 @@ import androidx.compose.ui.semantics.liveRegion
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.xtimms.psu.schedule.ui.theme.card
|
||||
import org.xtimms.psu.schedule.ui.motion.rememberReducedMotion
|
||||
|
||||
// Placeholders in the shape of what is loading: the first timetable of a group, a teacher's day,
|
||||
// the catalog. The screen keeps its layout instead of a lone spinner in the middle.
|
||||
@@ -0,0 +1,239 @@
|
||||
package org.xtimms.psu.schedule.ui.components
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.animate
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.gestures.draggable
|
||||
import androidx.compose.foundation.gestures.rememberDraggableState
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.drawscope.clipRect
|
||||
import androidx.compose.ui.graphics.drawscope.scale
|
||||
import androidx.compose.ui.graphics.drawscope.translate
|
||||
import androidx.compose.ui.graphics.lerp
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.graphics.vector.rememberVectorPainter
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.semantics.CustomAccessibilityAction
|
||||
import androidx.compose.ui.semantics.customActions
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import org.xtimms.psu.schedule.ui.motion.rememberReducedMotion
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.hypot
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
// Swiping a row sideways does something with it, as in Buckwheat's history: a strip opens under
|
||||
// the row with the action's icon; past the threshold the action's colour floods it in a circle
|
||||
// from the icon, the icon bounces and the phone ticks. Letting go there runs the action, and the
|
||||
// row always springs back into place.
|
||||
|
||||
/** One side's action: its icon, its name for TalkBack, its colours and what it does. */
|
||||
class SwipeAction(
|
||||
val icon: ImageVector,
|
||||
val label: String,
|
||||
val color: Color,
|
||||
val onColor: Color,
|
||||
val run: () -> Unit,
|
||||
)
|
||||
|
||||
/**
|
||||
* The swipe hint: until the student has swiped a row once, one row on the screen rocks to each
|
||||
* side by itself, showing the actions under it. [tried] remembers that they found it.
|
||||
*/
|
||||
@Stable
|
||||
class SwipeHint(val show: Boolean, val tried: () -> Unit) {
|
||||
internal var owner by mutableStateOf<Any?>(null)
|
||||
}
|
||||
|
||||
val LocalSwipeHint = staticCompositionLocalOf<SwipeHint?> { null }
|
||||
|
||||
/**
|
||||
* A row with [start] (swiped to the right) and [end] (to the left) actions. Either may be null;
|
||||
* with both null it is just [content]. TalkBack users get the same actions in the actions menu.
|
||||
*/
|
||||
@Composable
|
||||
fun SwipeActions(
|
||||
start: SwipeAction?,
|
||||
end: SwipeAction?,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
if (start == null && end == null) return Box(modifier) { content() }
|
||||
// The row's shift: set straight from the finger, so a late drag event can never stop the
|
||||
// spring that brings it back.
|
||||
var x by remember { mutableFloatStateOf(0f) }
|
||||
var width by remember { mutableFloatStateOf(0f) }
|
||||
val scope = rememberCoroutineScope()
|
||||
var settling by remember { mutableStateOf<Job?>(null) }
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val reduced = rememberReducedMotion()
|
||||
val startNow by rememberUpdatedState(start)
|
||||
val endNow by rememberUpdatedState(end)
|
||||
// A third of the row, but never a hair-trigger on a narrow one.
|
||||
val threshold = maxOf(width * 0.33f, with(LocalDensity.current) { 88.dp.toPx() })
|
||||
val armed = when {
|
||||
x > threshold -> start
|
||||
x < -threshold -> end
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun settle() {
|
||||
settling?.cancel()
|
||||
settling = scope.launch {
|
||||
animate(x, 0f, animationSpec = spring(dampingRatio = 0.75f, stiffness = Spring.StiffnessMediumLow)) { v, _ -> x = v }
|
||||
}
|
||||
}
|
||||
|
||||
// The hint, on the first row that asks for it while the student has never swiped.
|
||||
val hint = LocalSwipeHint.current
|
||||
val me = remember { Any() }
|
||||
var touched by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(hint) { if (hint?.show == true && hint.owner == null) hint.owner = me }
|
||||
DisposableEffect(hint) { onDispose { if (hint?.owner == me) hint.owner = null } }
|
||||
val hinting = hint?.show == true && hint.owner === me && !touched && !reduced && width > 0f
|
||||
LaunchedEffect(hinting) {
|
||||
if (!hinting) return@LaunchedEffect
|
||||
// Half way to the threshold and back, to one side and then the other, as in Buckwheat.
|
||||
var side = if (start != null) 1f else -1f
|
||||
try {
|
||||
while (true) {
|
||||
delay(1000)
|
||||
animate(0f, side * threshold / 2, animationSpec = tween(500, easing = FastOutSlowInEasing)) { v, _ -> x = v }
|
||||
animate(x, 0f, animationSpec = tween(500, easing = FastOutSlowInEasing)) { v, _ -> x = v }
|
||||
if ((side > 0 && endNow != null) || (side < 0 && startNow != null)) side = -side
|
||||
}
|
||||
} finally {
|
||||
if (x != 0f) settle()
|
||||
}
|
||||
}
|
||||
|
||||
// The flood from the icon and the icon's bounce, when the threshold is passed.
|
||||
val flood = remember { Animatable(0f) }
|
||||
val bounce = remember { Animatable(1f) }
|
||||
var wasArmed by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(armed != null) {
|
||||
val on = armed != null
|
||||
if (on == wasArmed) return@LaunchedEffect
|
||||
wasArmed = on
|
||||
haptic.performHapticFeedback(if (on) HapticFeedbackType.GestureThresholdActivate else HapticFeedbackType.SegmentFrequentTick)
|
||||
if (reduced) {
|
||||
flood.snapTo(if (on) 1f else 0f)
|
||||
return@LaunchedEffect
|
||||
}
|
||||
if (on) {
|
||||
launch { flood.animateTo(1f, tween(350)) }
|
||||
bounce.snapTo(0.8f)
|
||||
bounce.animateTo(1.25f, spring(dampingRatio = Spring.DampingRatioHighBouncy, stiffness = Spring.StiffnessMedium))
|
||||
bounce.animateTo(1f, spring(dampingRatio = Spring.DampingRatioLowBouncy))
|
||||
} else {
|
||||
launch { bounce.animateTo(1f) }
|
||||
flood.animateTo(0f, tween(200))
|
||||
}
|
||||
}
|
||||
|
||||
val rest = MaterialTheme.colorScheme.surfaceContainerHighest
|
||||
val restInk = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
val startIcon = start?.let { rememberVectorPainter(it.icon) }
|
||||
val endIcon = end?.let { rememberVectorPainter(it.icon) }
|
||||
val drag = rememberDraggableState { delta ->
|
||||
val max = if (startNow != null) width else 0f
|
||||
val min = if (endNow != null) -width else 0f
|
||||
x = (x + delta).coerceIn(min, max)
|
||||
}
|
||||
Box(
|
||||
modifier
|
||||
.clipToBounds()
|
||||
.onSizeChanged { width = it.width.toFloat() }
|
||||
.semantics {
|
||||
customActions = listOfNotNull(
|
||||
start?.let { CustomAccessibilityAction(it.label) { it.run(); true } },
|
||||
end?.let { CustomAccessibilityAction(it.label) { it.run(); true } },
|
||||
)
|
||||
}
|
||||
// A finger on the row stops the hint there.
|
||||
.pointerInput(Unit) {
|
||||
awaitEachGesture {
|
||||
awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
|
||||
touched = true
|
||||
}
|
||||
}
|
||||
.drawBehind {
|
||||
if (x == 0f) return@drawBehind
|
||||
val action = (if (x > 0) start else end) ?: return@drawBehind
|
||||
val painter = (if (x > 0) startIcon else endIcon) ?: return@drawBehind
|
||||
// Only the strip the row has uncovered, square at its ends.
|
||||
val left = if (x > 0) 0f else size.width + x
|
||||
val right = if (x > 0) x else size.width
|
||||
clipRect(left, 0f, right, size.height) {
|
||||
drawRect(rest)
|
||||
val icon = 24.dp.toPx()
|
||||
val margin = 24.dp.toPx()
|
||||
val center = Offset(if (x > 0) margin + icon / 2 else size.width - margin - icon / 2, size.height / 2)
|
||||
if (flood.value > 0f) drawCircle(action.color, radius = hypot(size.width, size.height) * flood.value, center = center)
|
||||
val ink = lerp(restInk, action.onColor, flood.value)
|
||||
// The icon fades in as the strip opens.
|
||||
val shown = (abs(x) / (margin * 2 + icon)).coerceIn(0f, 1f)
|
||||
translate(center.x - icon / 2, center.y - icon / 2) {
|
||||
scale(bounce.value, pivot = Offset(icon / 2, icon / 2)) {
|
||||
with(painter) { draw(Size(icon, icon), alpha = shown, colorFilter = ColorFilter.tint(ink)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.draggable(
|
||||
drag,
|
||||
Orientation.Horizontal,
|
||||
onDragStarted = {
|
||||
settling?.cancel()
|
||||
hint?.tried()
|
||||
},
|
||||
onDragStopped = {
|
||||
val action = when {
|
||||
x > threshold -> startNow
|
||||
x < -threshold -> endNow
|
||||
else -> null
|
||||
}
|
||||
settle()
|
||||
action?.run?.invoke()
|
||||
},
|
||||
),
|
||||
) {
|
||||
Box(Modifier.offset { IntOffset(x.roundToInt(), 0) }) { content() }
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package org.xtimms.psu.schedule.ui
|
||||
package org.xtimms.psu.schedule.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
@@ -42,6 +42,14 @@ class UndoHost(val state: SnackbarHostState, private val scope: CoroutineScope)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A message with nothing to take back: "У предмета больше нет пар в расписании". */
|
||||
fun say(message: String) {
|
||||
scope.launch {
|
||||
state.currentSnackbarData?.dismiss()
|
||||
state.showSnackbar(message, duration = SnackbarDuration.Short)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val LocalUndo = staticCompositionLocalOf<UndoHost?> { null }
|
||||
@@ -0,0 +1,13 @@
|
||||
package org.xtimms.psu.schedule.ui.groups
|
||||
|
||||
private val FACULTIES = mapOf(
|
||||
"Финансово-экономический факультет" to "ФЭФ",
|
||||
"Факультет информационных технологий" to "ФИТ",
|
||||
"Факультет компьютерных наук и электроники" to "ФКНЭ",
|
||||
"Гуманитарный факультет" to "ГФ",
|
||||
"Инженерно-строительный факультет" to "ИСФ",
|
||||
"Механико-технологический факультет" to "МТФ",
|
||||
"Юридический факультет" to "ЮФ",
|
||||
)
|
||||
|
||||
fun facultyShort(name: String): String? = name.takeIf { it.isNotEmpty() }?.let { FACULTIES[it] ?: it }
|
||||
@@ -1,6 +1,6 @@
|
||||
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||
|
||||
package org.xtimms.psu.schedule.ui
|
||||
package org.xtimms.psu.schedule.ui.groups
|
||||
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.foundation.layout.height
|
||||
@@ -57,6 +57,22 @@ import org.xtimms.psu.schedule.data.attempt
|
||||
import org.xtimms.psu.schedule.data.GroupsResponse
|
||||
import org.xtimms.psu.schedule.data.SavedGroup
|
||||
import org.xtimms.psu.schedule.data.choiceKey
|
||||
import org.xtimms.psu.schedule.ui.MainViewModel
|
||||
import org.xtimms.psu.schedule.ui.components.Banner
|
||||
import org.xtimms.psu.schedule.ui.components.CardRow
|
||||
import org.xtimms.psu.schedule.ui.components.ConnectedChoice
|
||||
import org.xtimms.psu.schedule.ui.components.ContentColumn
|
||||
import org.xtimms.psu.schedule.ui.components.Empty
|
||||
import org.xtimms.psu.schedule.ui.components.Heading
|
||||
import org.xtimms.psu.schedule.ui.components.ListCard
|
||||
import org.xtimms.psu.schedule.ui.components.RowDivider
|
||||
import org.xtimms.psu.schedule.ui.components.RowsSkeleton
|
||||
import org.xtimms.psu.schedule.ui.components.SearchField
|
||||
import org.xtimms.psu.schedule.ui.components.appBarColors
|
||||
import org.xtimms.psu.schedule.ui.components.avatarLabel
|
||||
import org.xtimms.psu.schedule.ui.components.navBarPadding
|
||||
import org.xtimms.psu.schedule.ui.lesson.subjectColor
|
||||
import org.xtimms.psu.schedule.ui.notes.open
|
||||
|
||||
private val PLAIN_PERIOD = Regex("^(осенний|весенний) семестр$|^расписание из word$", RegexOption.IGNORE_CASE)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package org.xtimms.psu.schedule.ui
|
||||
package org.xtimms.psu.schedule.ui.home
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -41,6 +41,7 @@ import org.xtimms.psu.schedule.ui.theme.card
|
||||
import java.time.Instant
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import org.xtimms.psu.schedule.ui.components.Heading
|
||||
|
||||
/** "3 изменения". */
|
||||
fun changesLabel(n: Int) = "$n ${plural(n, "изменение", "изменения", "изменений")}"
|
||||
@@ -1,12 +1,11 @@
|
||||
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||
|
||||
package org.xtimms.psu.schedule.ui
|
||||
package org.xtimms.psu.schedule.ui.home
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowLeft
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
@@ -70,7 +69,6 @@ import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -84,15 +82,12 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.delay
|
||||
import org.xtimms.psu.schedule.data.DayPlace
|
||||
import org.xtimms.psu.schedule.data.Lesson
|
||||
import org.xtimms.psu.schedule.data.Timetable
|
||||
import org.xtimms.psu.schedule.data.longLabel
|
||||
import org.xtimms.psu.schedule.data.minutesLabel
|
||||
import org.xtimms.psu.schedule.data.monday
|
||||
import org.xtimms.psu.schedule.data.nowTime
|
||||
import org.xtimms.psu.schedule.data.parseTime
|
||||
import org.xtimms.psu.schedule.data.plural
|
||||
import org.xtimms.psu.schedule.data.today
|
||||
import org.xtimms.psu.schedule.data.weekSpan
|
||||
@@ -101,28 +96,49 @@ import org.xtimms.psu.schedule.data.withPlace
|
||||
import java.time.Duration
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalTime
|
||||
|
||||
/** One time slot of a day: parallel lessons of subgroups share it. */
|
||||
data class Slot(val date: LocalDate, val start: LocalTime, val end: LocalTime, val lessons: List<Lesson>)
|
||||
|
||||
fun MainViewModel.slotsOn(t: Timetable, date: LocalDate, subgroup: String?): List<Slot> =
|
||||
repository.lessonsOn(t, date, subgroup, hidden)
|
||||
.groupBy { it.start }
|
||||
.map { (start, lessons) -> Slot(date, parseTime(start), lessonEnd(lessons.first(), t), lessons) }
|
||||
.sortedBy { it.start }
|
||||
|
||||
/** The current time, refreshed every half minute while the screen is shown. */
|
||||
@Composable
|
||||
fun rememberClock(): LocalTime {
|
||||
var clock by remember { mutableStateOf(nowTime()) }
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
delay(30_000)
|
||||
clock = nowTime()
|
||||
}
|
||||
}
|
||||
return clock
|
||||
}
|
||||
import org.xtimms.psu.schedule.ui.AppNav
|
||||
import org.xtimms.psu.schedule.ui.MainViewModel
|
||||
import org.xtimms.psu.schedule.ui.MenuButton
|
||||
import org.xtimms.psu.schedule.ui.components.Banner
|
||||
import org.xtimms.psu.schedule.ui.components.CenteredEmpty
|
||||
import org.xtimms.psu.schedule.ui.components.ContentColumn
|
||||
import org.xtimms.psu.schedule.ui.components.DetailPane
|
||||
import org.xtimms.psu.schedule.ui.components.Empty
|
||||
import org.xtimms.psu.schedule.ui.components.Heading
|
||||
import org.xtimms.psu.schedule.ui.components.ListCard
|
||||
import org.xtimms.psu.schedule.ui.components.LocalUndo
|
||||
import org.xtimms.psu.schedule.ui.components.OverviewSkeleton
|
||||
import org.xtimms.psu.schedule.ui.components.PaneRow
|
||||
import org.xtimms.psu.schedule.ui.components.TimeText
|
||||
import org.xtimms.psu.schedule.ui.components.WeekMark
|
||||
import org.xtimms.psu.schedule.ui.components.appBarColors
|
||||
import org.xtimms.psu.schedule.ui.components.navBarPadding
|
||||
import org.xtimms.psu.schedule.ui.components.twoPane
|
||||
import org.xtimms.psu.schedule.ui.lesson.AttendanceCard
|
||||
import org.xtimms.psu.schedule.ui.lesson.DayPlaces
|
||||
import org.xtimms.psu.schedule.ui.lesson.LocalDisplay
|
||||
import org.xtimms.psu.schedule.ui.lesson.SessionBadge
|
||||
import org.xtimms.psu.schedule.ui.lesson.SessionDot
|
||||
import org.xtimms.psu.schedule.ui.lesson.Slot
|
||||
import org.xtimms.psu.schedule.ui.lesson.SubjectDot
|
||||
import org.xtimms.psu.schedule.ui.lesson.placesOn
|
||||
import org.xtimms.psu.schedule.ui.lesson.rememberClock
|
||||
import org.xtimms.psu.schedule.ui.lesson.rememberLessonOpener
|
||||
import org.xtimms.psu.schedule.ui.lesson.session
|
||||
import org.xtimms.psu.schedule.ui.lesson.sessionColors
|
||||
import org.xtimms.psu.schedule.ui.lesson.slotsOn
|
||||
import org.xtimms.psu.schedule.ui.motion.AnimatedItem
|
||||
import org.xtimms.psu.schedule.ui.motion.Entrance
|
||||
import org.xtimms.psu.schedule.ui.motion.ExpressiveRefresh
|
||||
import org.xtimms.psu.schedule.ui.motion.RollingText
|
||||
import org.xtimms.psu.schedule.ui.motion.materialSharedAxisX
|
||||
import org.xtimms.psu.schedule.ui.motion.morphOrigin
|
||||
import org.xtimms.psu.schedule.ui.motion.rememberSlideDistance
|
||||
import org.xtimms.psu.schedule.ui.notes.LocalNotes
|
||||
import org.xtimms.psu.schedule.ui.notes.NoteEditor
|
||||
import org.xtimms.psu.schedule.ui.notes.NoteRow
|
||||
import org.xtimms.psu.schedule.ui.schedule.WeekGrid
|
||||
import org.xtimms.psu.schedule.ui.lesson.lessonPress
|
||||
|
||||
@Composable
|
||||
fun HomeScreen(vm: MainViewModel, nav: AppNav) {
|
||||
@@ -331,7 +347,7 @@ private fun TasksCard(vm: MainViewModel, onEdit: (Note) -> Unit, onAll: () -> Un
|
||||
},
|
||||
) {
|
||||
(if (expanded) pending else pending.take(4)).forEach { note ->
|
||||
NoteRow(note, onToggle = { vm.toggleNote(note.id) }, onClick = { onEdit(note) }, showSubject = true)
|
||||
NoteRow(note, onToggle = { vm.toggleNote(note.id) }, onClick = { onEdit(note) }, showSubject = true, onPostpone = { vm.postponeNote(note, it) })
|
||||
}
|
||||
Row(Modifier.padding(start = 12.dp)) {
|
||||
if (pending.size > 4) TextButton(onClick = { expanded = !expanded }) {
|
||||
@@ -505,11 +521,6 @@ private fun DayButton(onClick: () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SubjectDot(subject: String, size: Dp = 12.dp, modifier: Modifier = Modifier) {
|
||||
Box(modifier.size(size).background(subjectColor(subject).container, CircleShape))
|
||||
}
|
||||
|
||||
private fun Slot.timeLabel() = "$start → $end"
|
||||
|
||||
/** The next lesson in full: subject, time with a countdown, room and teacher. */
|
||||
@@ -521,7 +532,7 @@ private fun FeaturedLesson(lesson: Lesson, slot: Slot, clock: LocalTime, onClick
|
||||
.fillMaxWidth()
|
||||
.morphOrigin("featured|${slot.date}|${lesson.key}|${lesson.start}", MaterialTheme.colorScheme.card, 12.dp)
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
.clickable(onClickLabel = "Подробнее", role = Role.Button, onClick = onClick),
|
||||
.lessonPress(lesson, slot.date, onClick),
|
||||
) {
|
||||
SubjectDot(lesson.subject, 14.dp, Modifier.padding(top = 8.dp))
|
||||
Spacer(Modifier.width(16.dp))
|
||||
@@ -563,7 +574,7 @@ fun CompactLesson(lesson: Lesson, slot: Slot, onClick: (() -> Unit)?) {
|
||||
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)
|
||||
.lessonPress(lesson, slot.date, onClick)
|
||||
else Modifier,
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -1,6 +1,6 @@
|
||||
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
|
||||
package org.xtimms.psu.schedule.ui
|
||||
package org.xtimms.psu.schedule.ui.home
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -42,6 +42,8 @@ import org.xtimms.psu.schedule.data.weekdayName
|
||||
import org.xtimms.psu.schedule.ui.theme.card
|
||||
import java.time.LocalDate
|
||||
import java.time.temporal.ChronoUnit
|
||||
import org.xtimms.psu.schedule.ui.MainViewModel
|
||||
import org.xtimms.psu.schedule.ui.components.Heading
|
||||
|
||||
/** Upcoming exams of the open group, soonest first; the cache of another group is ignored. */
|
||||
@Composable
|
||||
@@ -1,6 +1,6 @@
|
||||
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
|
||||
package org.xtimms.psu.schedule.ui
|
||||
package org.xtimms.psu.schedule.ui.lesson
|
||||
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
@@ -34,6 +34,12 @@ import org.xtimms.psu.schedule.data.Mark
|
||||
import org.xtimms.psu.schedule.data.nowTime
|
||||
import org.xtimms.psu.schedule.data.today
|
||||
import java.time.LocalDate
|
||||
import org.xtimms.psu.schedule.ui.MainViewModel
|
||||
import org.xtimms.psu.schedule.ui.components.ConnectedChoice
|
||||
import org.xtimms.psu.schedule.ui.components.Heading
|
||||
import org.xtimms.psu.schedule.ui.components.ListCard
|
||||
import org.xtimms.psu.schedule.ui.components.RowDivider
|
||||
import org.xtimms.psu.schedule.ui.components.TimeText
|
||||
|
||||
/** Attendance marks of the open group, for cards deep in the tree. */
|
||||
val LocalMarks = staticCompositionLocalOf<List<Mark>> { emptyList() }
|
||||
@@ -0,0 +1,101 @@
|
||||
package org.xtimms.psu.schedule.ui.lesson
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.DirectionsWalk
|
||||
import androidx.compose.material.icons.rounded.Coffee
|
||||
import androidx.compose.material.icons.rounded.DirectionsBus
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.xtimms.psu.schedule.data.Lesson
|
||||
import org.xtimms.psu.schedule.data.minutesLabel
|
||||
import java.time.Duration
|
||||
|
||||
// Between two lessons of a day: a free window where a pair is missing, a walk to another
|
||||
// building, a ride to another town. Breaks of 10–30 minutes are the usual and say nothing.
|
||||
|
||||
/** What lies between two lessons: the gap, and where the next one is, when it is elsewhere. */
|
||||
class Between(val minutes: Long, val window: Boolean, val building: Pair<String, String>?, val town: String?, val away: String?)
|
||||
|
||||
/** From this many minutes the gap is a window: a pair is missing, not a break. */
|
||||
private const val WINDOW = 45L
|
||||
|
||||
/** The building of a lesson's room, its letters: "Г" of "305Г", "" for "121"; null when unknown. */
|
||||
private fun Lesson.building(): String? =
|
||||
if (distance) null else rooms.firstOrNull()?.uppercase()?.trim()?.takeLastWhile { it.isLetter() }
|
||||
|
||||
/** "из Полоцка", "из Междуречья". */
|
||||
private fun fromTown(town: String): String = "из " + when {
|
||||
town.endsWith("е") -> town.dropLast(1) + "я"
|
||||
town.endsWith("к") -> town + "а"
|
||||
else -> town
|
||||
}
|
||||
|
||||
/**
|
||||
* Between [a] and [b], two slots in a row of one day; null when nothing is worth a line. Only
|
||||
* a slot with one lesson has a room to leave from or go to.
|
||||
*/
|
||||
fun between(a: Slot, b: Slot): Between? {
|
||||
val minutes = Duration.between(a.end, b.start).toMinutes()
|
||||
if (minutes < 0) return null
|
||||
val from = a.lessons.singleOrNull()
|
||||
val to = b.lessons.singleOrNull()
|
||||
val window = minutes >= WINDOW
|
||||
val moved = from != null && to != null && from.place != to.place
|
||||
val town = if (moved) to?.place else null
|
||||
val away = if (moved && to?.place == null) from?.place else null
|
||||
val building = if (!moved && from != null && to != null) {
|
||||
val x = from.building()
|
||||
val y = to.building()
|
||||
if (x != null && y != null && x != y) from.rooms.first() to to.rooms.first() else null
|
||||
} else null
|
||||
if (!window && !moved && building == null) return null
|
||||
return Between(minutes, window, building, town, away)
|
||||
}
|
||||
|
||||
/** The line between two lesson cards: "Окно 1 ч 35 мин · Другой корпус: 305Г → 208А". */
|
||||
@Composable
|
||||
fun BetweenRow(b: Between, modifier: Modifier = Modifier) {
|
||||
val parts = listOfNotNull(
|
||||
if (b.window) "Окно ${minutesLabel(b.minutes)}" else null,
|
||||
b.town?.let { "Переезд в $it" } ?: b.away?.let { "Переезд " + fromTown(it) },
|
||||
b.building?.let { (x, y) -> "Другой корпус: $x → $y" },
|
||||
)
|
||||
// A ride takes long: with little time it is the one thing to see.
|
||||
val ride = b.town != null || b.away != null
|
||||
val tight = ride && b.minutes < 60
|
||||
val ink = if (tight) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
val text = parts.joinToString(" · ") + if (!b.window) " · перерыв ${minutesLabel(b.minutes)}" else ""
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 20.dp)
|
||||
.clearAndSetSemantics { contentDescription = text },
|
||||
) {
|
||||
Icon(
|
||||
when {
|
||||
ride -> Icons.Rounded.DirectionsBus
|
||||
b.building != null -> Icons.AutoMirrored.Rounded.DirectionsWalk
|
||||
else -> Icons.Rounded.Coffee
|
||||
},
|
||||
contentDescription = null,
|
||||
tint = ink,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text(text, style = MaterialTheme.typography.bodyMedium, color = ink)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
@file:OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
||||
|
||||
package org.xtimms.psu.schedule.ui.lesson
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.OpenInNew
|
||||
import androidx.compose.material.icons.rounded.AddTask
|
||||
import androidx.compose.material.icons.rounded.Share
|
||||
import androidx.compose.material.icons.rounded.VisibilityOff
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.launch
|
||||
import org.xtimms.psu.schedule.data.Lesson
|
||||
import org.xtimms.psu.schedule.data.hideKey
|
||||
import org.xtimms.psu.schedule.data.longLabel
|
||||
import org.xtimms.psu.schedule.data.shortWeekday
|
||||
import org.xtimms.psu.schedule.data.withPlace
|
||||
import org.xtimms.psu.schedule.ui.MainViewModel
|
||||
import org.xtimms.psu.schedule.ui.components.ConnectedChoice
|
||||
import org.xtimms.psu.schedule.ui.components.LocalUndo
|
||||
import org.xtimms.psu.schedule.ui.motion.LocalMorph
|
||||
import org.xtimms.psu.schedule.ui.motion.MorphKind
|
||||
import org.xtimms.psu.schedule.ui.notes.NoteEditor
|
||||
import org.xtimms.psu.schedule.ui.notes.NoteTarget
|
||||
import org.xtimms.psu.schedule.ui.notes.nextOccurrence
|
||||
import java.time.LocalDate
|
||||
|
||||
// A long press on a lesson: what one does with it most, without opening its page first.
|
||||
|
||||
/** Opens the actions of a lesson on a date; null where there is no app around (previews). */
|
||||
val LocalLessonActions = staticCompositionLocalOf<((Lesson, LocalDate) -> Unit)?> { null }
|
||||
|
||||
/**
|
||||
* A lesson's tap and long press: the tap opens its page, the long press its actions, with a
|
||||
* buzz. TalkBack offers both ("Подробнее", "Действия").
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun Modifier.lessonPress(lesson: Lesson, date: LocalDate?, onClick: () -> Unit): Modifier {
|
||||
val actions = LocalLessonActions.current
|
||||
val haptic = LocalHapticFeedback.current
|
||||
if (actions == null || date == null) return clickable(onClickLabel = "Подробнее", role = Role.Button, onClick = onClick)
|
||||
return combinedClickable(
|
||||
onClickLabel = "Подробнее",
|
||||
role = Role.Button,
|
||||
onLongClickLabel = "Действия",
|
||||
onLongClick = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
actions(lesson, date)
|
||||
},
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
||||
|
||||
/** Provides [LocalLessonActions] to [content] and shows the actions sheet when asked. */
|
||||
@Composable
|
||||
fun LessonActionsHost(vm: MainViewModel, content: @Composable () -> Unit) {
|
||||
var target by remember { mutableStateOf<Pair<Lesson, LocalDate>?>(null) }
|
||||
// The card pressed, so "Подробнее" grows out of it even after the sheet.
|
||||
var from by remember { mutableStateOf<String?>(null) }
|
||||
val morph = LocalMorph.current
|
||||
val open: (Lesson, LocalDate) -> Unit = remember(morph) {
|
||||
{ lesson, date ->
|
||||
from = morph?.recentTouch()
|
||||
target = lesson to date
|
||||
}
|
||||
}
|
||||
CompositionLocalProvider(LocalLessonActions provides open) { content() }
|
||||
target?.let { (lesson, date) -> LessonActionsSheet(vm, lesson, date, from) { target = null } }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LessonActionsSheet(vm: MainViewModel, lesson: Lesson, date: LocalDate, from: String?, onDismiss: () -> Unit) {
|
||||
val sheet = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
val undo = LocalUndo.current
|
||||
val morph = LocalMorph.current
|
||||
val timetable by vm.timetable.collectAsStateWithLifecycle()
|
||||
val settings by vm.settings.collectAsStateWithLifecycle()
|
||||
val marks by vm.marks.collectAsStateWithLifecycle()
|
||||
var adding by remember { mutableStateOf(false) }
|
||||
val end = timetable?.let { lessonEnd(lesson, it) }
|
||||
val where = if (lesson.distance) "дистанционно" else withPlace(lesson.rooms.joinToString(", ").takeIf { it.isNotEmpty() }?.let { "ауд. $it" }, lesson.place)
|
||||
val summary = listOfNotNull(
|
||||
date.shortWeekday().lowercase() + ", " + date.longLabel(),
|
||||
lesson.start + (end?.let { " – $it" } ?: ""),
|
||||
where,
|
||||
).joinToString(" · ")
|
||||
// Closes the sheet first, then does [then]: the next screen or dialog is not under it.
|
||||
fun close(then: () -> Unit = {}) {
|
||||
scope.launch { sheet.hide() }.invokeOnCompletion {
|
||||
onDismiss()
|
||||
then()
|
||||
}
|
||||
}
|
||||
|
||||
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheet) {
|
||||
Column(Modifier.navigationBarsPadding().padding(bottom = 16.dp)) {
|
||||
Column(Modifier.padding(horizontal = 24.dp).padding(bottom = 12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(lesson.subject, style = MaterialTheme.typography.titleLargeEmphasized)
|
||||
Text(summary, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
ActionRow(Icons.AutoMirrored.Rounded.OpenInNew, "Подробнее") {
|
||||
close {
|
||||
morph?.open(MorphKind.Page, from = from) { done -> LessonPage(lesson, date, vm, done) }
|
||||
}
|
||||
}
|
||||
if (vm.currentGroupKey() != null) ActionRow(Icons.Rounded.AddTask, "Задание или заметка") { adding = true }
|
||||
// Once the lesson has begun: was there or not, in one tap.
|
||||
if (vm.currentGroupKey() != null && lessonStarted(date, lesson.start)) {
|
||||
val mark = marks.forLesson(lesson.subject, date, lesson.start)
|
||||
ConnectedChoice(
|
||||
options = listOf<Pair<Boolean?, String>>(true to "Был", false to "Пропустил"),
|
||||
selected = mark?.present,
|
||||
onSelect = { vm.mark(lesson, date, if (it == mark?.present) null else it) },
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
ActionRow(Icons.Rounded.Share, "Поделиться") {
|
||||
val text = listOfNotNull(lesson.subject, lesson.type.takeIf { it.isNotEmpty() }, summary, lesson.teachers.joinToString(", ").ifEmpty { null })
|
||||
.joinToString("\n")
|
||||
close {
|
||||
context.startActivity(Intent.createChooser(Intent(Intent.ACTION_SEND).setType("text/plain").putExtra(Intent.EXTRA_TEXT, text), null))
|
||||
}
|
||||
}
|
||||
ActionRow(Icons.Rounded.VisibilityOff, "Скрыть такие занятия") {
|
||||
close {
|
||||
vm.setHidden(lesson.hideKey, true)
|
||||
undo?.offer("Скрыто: «${lesson.subject}»", onUndo = { vm.setHidden(lesson.hideKey, false) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (adding) {
|
||||
val group = vm.currentGroupKey() ?: return
|
||||
NoteEditor(
|
||||
target = NoteTarget(
|
||||
subject = lesson.subject,
|
||||
date = date,
|
||||
start = lesson.start,
|
||||
next = timetable?.let { vm.nextOccurrence(it, lesson.subject, date, lesson.start, settings?.subgroup) },
|
||||
),
|
||||
editing = null,
|
||||
groupKey = group,
|
||||
onSave = {
|
||||
vm.saveNote(it)
|
||||
adding = false
|
||||
close()
|
||||
},
|
||||
onDelete = {},
|
||||
onDismiss = { adding = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActionRow(icon: ImageVector, label: String, onClick: () -> Unit) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(role = Role.Button, onClick = onClick)
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||
) {
|
||||
Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(24.dp))
|
||||
Spacer(Modifier.width(20.dp))
|
||||
Text(label, style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||
|
||||
package org.xtimms.psu.schedule.ui.lesson
|
||||
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.material.icons.rounded.Close
|
||||
import androidx.compose.material.icons.rounded.Check
|
||||
import androidx.compose.material.icons.rounded.VisibilityOff
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import org.xtimms.psu.schedule.data.hideKey
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import org.xtimms.psu.schedule.ui.theme.card
|
||||
import org.xtimms.psu.schedule.ui.theme.page
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.EventRepeat
|
||||
import androidx.compose.material.icons.rounded.Groups
|
||||
import androidx.compose.material.icons.rounded.LocationCity
|
||||
import androidx.compose.material.icons.rounded.LocationOn
|
||||
import androidx.compose.material.icons.rounded.Person
|
||||
import androidx.compose.material.icons.rounded.Schedule
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.xtimms.psu.schedule.data.Bell
|
||||
import org.xtimms.psu.schedule.data.Lesson
|
||||
import org.xtimms.psu.schedule.data.withPlace
|
||||
import java.time.LocalDate
|
||||
import org.xtimms.psu.schedule.ui.MainViewModel
|
||||
import org.xtimms.psu.schedule.ui.components.Banner
|
||||
import org.xtimms.psu.schedule.ui.components.Heading
|
||||
import org.xtimms.psu.schedule.ui.components.LocalUndo
|
||||
import org.xtimms.psu.schedule.ui.components.TimeText
|
||||
import org.xtimms.psu.schedule.ui.components.UndoArea
|
||||
import org.xtimms.psu.schedule.ui.motion.EmphasizedDecelerate
|
||||
import org.xtimms.psu.schedule.ui.motion.RollingText
|
||||
import org.xtimms.psu.schedule.ui.motion.morphOrigin
|
||||
import org.xtimms.psu.schedule.ui.motion.progressBorder
|
||||
import org.xtimms.psu.schedule.ui.motion.rememberReducedMotion
|
||||
import org.xtimms.psu.schedule.ui.notes.LessonNotes
|
||||
import org.xtimms.psu.schedule.ui.notes.LocalNotes
|
||||
import org.xtimms.psu.schedule.ui.notes.NotesHint
|
||||
import org.xtimms.psu.schedule.ui.notes.forLesson
|
||||
import org.xtimms.psu.schedule.ui.lesson.forLesson
|
||||
import org.xtimms.psu.schedule.ui.motion.morphContent
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.ui.draw.clip
|
||||
|
||||
/** A lesson as a soft card in its subject's colour, like School Planner's day list. */
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun LessonCard(
|
||||
lesson: Lesson,
|
||||
time: String?,
|
||||
pair: Int? = null,
|
||||
date: LocalDate? = null,
|
||||
status: String? = null,
|
||||
running: Boolean = false,
|
||||
/** How far the lesson under way has gone, 0..1: a line runs round the card. */
|
||||
progress: Float? = null,
|
||||
onClick: (() -> Unit)?,
|
||||
/** Just added or changed by a republished document: the card glows twice, then [onFreshShown]. */
|
||||
fresh: Boolean = false,
|
||||
onFreshShown: () -> Unit = {},
|
||||
) {
|
||||
val color = subjectColor(lesson.subject)
|
||||
val show = LocalDisplay.current
|
||||
val session = lesson.session
|
||||
val content: @Composable () -> Unit = {
|
||||
Column(Modifier.padding(horizontal = 20.dp, vertical = 16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
// Exams, credits and consultations announce themselves before the subject.
|
||||
session?.let { SessionBadge(it, Modifier.padding(bottom = 2.dp)) }
|
||||
Row(verticalAlignment = Alignment.Top) {
|
||||
Text(lesson.subject, style = MaterialTheme.typography.titleMediumEmphasized, modifier = Modifier.weight(1f))
|
||||
status?.let {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.small,
|
||||
color = if (running) color.onSoft else color.onSoft.copy(alpha = 0.12f),
|
||||
contentColor = if (running) color.soft else color.onSoft,
|
||||
) {
|
||||
RollingText(it, style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
time?.let { TimeText(it + (pair?.takeIf { show.pairs }?.let { p -> " · $p пара" } ?: ""), style = MaterialTheme.typography.bodyLarge) }
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
if (lesson.distance) Meta(Icons.Rounded.LocationOn, "Дистанционно")
|
||||
else withPlace(
|
||||
lesson.rooms.takeIf { show.rooms && it.isNotEmpty() }?.let { it.joinToString(", ") + if (lesson.roomMoved) " (перенос)" else "" },
|
||||
lesson.place,
|
||||
)?.let { Meta(Icons.Rounded.LocationOn, it) }
|
||||
if (show.teachers && lesson.teachers.isNotEmpty()) Meta(Icons.Rounded.Person, lesson.teachers.joinToString(", "))
|
||||
lesson.subgroup?.let { Meta(Icons.Rounded.Groups, it) }
|
||||
}
|
||||
val tags = if (!show.tags) emptyList() else listOfNotNull(
|
||||
lesson.type.ifEmpty { null }?.takeIf { session == null },
|
||||
if (lesson.variant != "all") (if (lesson.variant == "upper") "белые недели" else "зелёные недели") else null,
|
||||
if (lesson.groups.size > 1) "общее занятие" else null,
|
||||
// German or French: a part of the group has this one, the rest the other.
|
||||
if (lesson.option != null) "на выбор" else null,
|
||||
when (lesson.audience) {
|
||||
"foreign" -> "для иностранных студентов"
|
||||
"local" -> "кроме иностранных студентов"
|
||||
else -> null
|
||||
},
|
||||
)
|
||||
val mark = date?.let { LocalMarks.current.forLesson(lesson.subject, it, lesson.start) }
|
||||
if (tags.isNotEmpty() || lesson.uncertain || mark != null) FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
) {
|
||||
mark?.let {
|
||||
SoftTag(if (it.present) "был" else "пропуск", color, icon = if (it.present) Icons.Rounded.Check else Icons.Rounded.Close)
|
||||
}
|
||||
tags.forEach { SoftTag(it, color) }
|
||||
if (lesson.uncertain) Tag("Сверьте недели", warning = true)
|
||||
}
|
||||
if (show.notes && lesson.condition.isNotEmpty()) Meta(Icons.Rounded.EventRepeat, "Пометка в Word: ${lesson.condition}")
|
||||
if (date != null) NotesHint(LocalNotes.current.forLesson(lesson.subject, date, lesson.start), color.onSoft)
|
||||
}
|
||||
}
|
||||
// A tapped card grows into its details page (Morph.kt).
|
||||
val glow = remember { androidx.compose.animation.core.Animatable(0f) }
|
||||
val reduced = rememberReducedMotion()
|
||||
LaunchedEffect(fresh) {
|
||||
if (!fresh) return@LaunchedEffect
|
||||
if (!reduced) repeat(2) {
|
||||
glow.animateTo(1f, tween(350, easing = EmphasizedDecelerate))
|
||||
glow.animateTo(0f, tween(450))
|
||||
}
|
||||
onFreshShown()
|
||||
}
|
||||
// The fill itself turns tertiary and back, so the text stays as crisp as on any card.
|
||||
val highlight = MaterialTheme.colorScheme.tertiaryContainer
|
||||
val fill = if (glow.value > 0f) androidx.compose.ui.graphics.lerp(color.soft, highlight, glow.value) else color.soft
|
||||
val morphKey = "card|$date|${lesson.key}|${lesson.start}"
|
||||
val origin = if (onClick != null) Modifier.morphOrigin(morphKey, color.soft, 16.dp) else Modifier
|
||||
// The line round a running card grows a little every half minute, never jumps.
|
||||
val shown by androidx.compose.animation.core.animateFloatAsState(progress ?: 0f, tween(1000), label = "progress")
|
||||
val live = if (running && progress != null) Modifier.progressBorder(shown, color.onSoft, 16.dp) else Modifier
|
||||
val border = when {
|
||||
running && progress != null -> null
|
||||
running -> BorderStroke(2.dp, color.onSoft)
|
||||
session != null -> BorderStroke(2.dp, session.outline())
|
||||
else -> null
|
||||
}
|
||||
if (onClick != null) Surface(
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = fill,
|
||||
contentColor = color.onSoft,
|
||||
border = border,
|
||||
// A tap opens the page, a long press the lesson's actions.
|
||||
modifier = Modifier.fillMaxWidth().then(origin).then(live).clip(MaterialTheme.shapes.large).lessonPress(lesson, date, onClick),
|
||||
// The page grows from the card's text; the card's fill is the container's.
|
||||
content = { Box(Modifier.morphContent(morphKey)) { content() } },
|
||||
) else Surface(
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = color.soft,
|
||||
contentColor = color.onSoft,
|
||||
border = border,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SoftTag(text: String, color: SubjectColor, icon: ImageVector? = null) {
|
||||
Surface(shape = MaterialTheme.shapes.small, color = color.onSoft.copy(alpha = 0.12f), contentColor = color.onSoft) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(start = if (icon != null) 6.dp else 8.dp, end = 8.dp, top = 3.dp, bottom = 3.dp),
|
||||
) {
|
||||
icon?.let {
|
||||
Icon(it, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
Spacer(Modifier.width(4.dp))
|
||||
}
|
||||
Text(text.replaceFirstChar { it.titlecase() }, style = MaterialTheme.typography.labelLarge)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Meta(icon: androidx.compose.ui.graphics.vector.ImageVector, text: String) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(icon, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(text, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TypeTag(type: String) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val (bg, fg) = when (type) {
|
||||
"Лекция" -> scheme.primaryContainer to scheme.onPrimaryContainer
|
||||
"Практика" -> scheme.tertiaryContainer to scheme.onTertiaryContainer
|
||||
"Экзамен", "Зачёт", "Защита курсовой работы" -> sessionColors().let { it.strong to it.onStrong }
|
||||
"Консультация" -> sessionColors().let { it.soft to it.onSoft }
|
||||
else -> scheme.secondaryContainer to scheme.onSecondaryContainer
|
||||
}
|
||||
Surface(shape = MaterialTheme.shapes.small, color = bg, contentColor = fg) {
|
||||
Text(type.ifEmpty { "Занятие" }, style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Tag(text: String, warning: Boolean = false) {
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.small,
|
||||
color = if (warning) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
contentColor = if (warning) MaterialTheme.colorScheme.onErrorContainer else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
) {
|
||||
Text(text, style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun LessonDetails(
|
||||
lesson: Lesson,
|
||||
bells: List<Bell>,
|
||||
date: LocalDate? = null,
|
||||
vm: MainViewModel? = null,
|
||||
onClose: () -> Unit = {},
|
||||
) {
|
||||
val bell = bells.firstOrNull { it.start == lesson.start }
|
||||
// Hiding closes the sheet, so its "Вернуть" goes to the screen; tasks deleted here, to the sheet.
|
||||
val screenUndo = LocalUndo.current
|
||||
UndoArea(bottom = false) {
|
||||
Column(
|
||||
Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(start = 24.dp, end = 24.dp, bottom = 32.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
TypeTag(lesson.type)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
SubjectDot(lesson.subject, 16.dp)
|
||||
Spacer(Modifier.width(14.dp))
|
||||
Heading(lesson.subject, style = MaterialTheme.typography.headlineSmallEmphasized)
|
||||
}
|
||||
Detail(
|
||||
Icons.Rounded.Schedule,
|
||||
bell?.let { "${it.pair} пара" } ?: "Время",
|
||||
lesson.start + (lesson.end?.let { " — $it" } ?: ""),
|
||||
bell?.halves?.takeIf { it.size > 1 }?.joinToString(", ") { it.joinToString("–") },
|
||||
)
|
||||
Detail(Icons.Rounded.Person, "Преподаватель", lesson.teachers.joinToString(", ").ifEmpty { "Не указан" })
|
||||
Detail(
|
||||
Icons.Rounded.LocationOn,
|
||||
"Аудитория",
|
||||
if (lesson.distance) "Дистанционно" else lesson.rooms.joinToString(", ").ifEmpty { "Не указана" },
|
||||
if (lesson.roomMoved) "В этот день — перенос аудитории" else null,
|
||||
)
|
||||
lesson.place?.let { Detail(Icons.Rounded.LocationCity, "Город", it, "Так указано в Word") }
|
||||
Detail(Icons.Rounded.Groups, "Группы", lesson.groups.joinToString(", "), lesson.subgroup)
|
||||
if (lesson.uncertain) Banner(
|
||||
"Пометку «${lesson.condition}» не удалось сопоставить с таблицей недель. Сверьте с Word.",
|
||||
offline = false,
|
||||
onClose = null,
|
||||
)
|
||||
if (date != null && vm != null) {
|
||||
LessonAttendance(vm, lesson, date)
|
||||
LessonNotes(vm, lesson, date)
|
||||
}
|
||||
Heading("Исходная запись", style = MaterialTheme.typography.titleSmallEmphasized)
|
||||
Surface(shape = MaterialTheme.shapes.large, color = MaterialTheme.colorScheme.surfaceContainerHigh) {
|
||||
Text(lesson.raw, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(16.dp).fillMaxWidth())
|
||||
}
|
||||
if (vm != null) {
|
||||
OutlinedButton(onClick = {
|
||||
vm.setHidden(lesson.hideKey, true)
|
||||
onClose()
|
||||
screenUndo?.offer("Скрыто: «${lesson.subject}»", onUndo = { vm.setHidden(lesson.hideKey, false) })
|
||||
}) {
|
||||
Icon(Icons.Rounded.VisibilityOff, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Скрыть такие занятия")
|
||||
}
|
||||
Text(
|
||||
"«${lesson.subject}»${lesson.type.takeIf { it.isNotEmpty() }?.let { " · ${it.lowercase()}" }.orEmpty()} пропадёт из расписания, " +
|
||||
"обзора, виджета и напоминаний во все недели. Вернуть можно в настройках.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Detail(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, value: String, extra: String? = null) {
|
||||
Row {
|
||||
Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 2.dp))
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Column {
|
||||
Text(label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(value, style = MaterialTheme.typography.bodyLargeEmphasized)
|
||||
extra?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||
|
||||
package org.xtimms.psu.schedule.ui.lesson
|
||||
|
||||
import androidx.compose.material.icons.rounded.Close
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import org.xtimms.psu.schedule.ui.theme.card
|
||||
import org.xtimms.psu.schedule.ui.theme.page
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import org.xtimms.psu.schedule.data.Lesson
|
||||
import java.time.LocalDate
|
||||
import org.xtimms.psu.schedule.ui.MainViewModel
|
||||
import org.xtimms.psu.schedule.ui.motion.LocalMorph
|
||||
import org.xtimms.psu.schedule.ui.motion.MorphKind
|
||||
|
||||
/**
|
||||
* Opens a lesson's details as a page growing out of the card that was tapped (Morph.kt); from
|
||||
* anywhere else (the week grid of a narrow column, a notification) it grows from the middle.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberLessonOpener(vm: MainViewModel): (Lesson, LocalDate?) -> Unit {
|
||||
val morph = LocalMorph.current
|
||||
return remember(morph, vm) {
|
||||
{ lesson, date -> morph?.open(MorphKind.Page) { close -> LessonPage(lesson, date, vm, close) } }
|
||||
}
|
||||
}
|
||||
|
||||
/** The details as a page: a close button over them; full screen on a phone, a panel on a tablet. */
|
||||
@Composable
|
||||
internal fun LessonPage(lesson: Lesson, date: LocalDate?, vm: MainViewModel, close: () -> Unit) {
|
||||
val timetable by vm.timetable.collectAsStateWithLifecycle()
|
||||
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||
// The phone's page covers the bars; a tablet's panel sits inside them.
|
||||
val insets = if (maxWidth >= 600.dp) Modifier else Modifier.windowInsetsPadding(WindowInsets.systemBars)
|
||||
Column(Modifier.fillMaxSize().then(insets)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(start = 8.dp, top = 4.dp, bottom = 8.dp)) {
|
||||
IconButton(onClick = close) { Icon(Icons.Rounded.Close, contentDescription = "Закрыть") }
|
||||
}
|
||||
LessonDetails(lesson, timetable?.bells.orEmpty(), date, vm, onClose = close)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package org.xtimms.psu.schedule.ui
|
||||
package org.xtimms.psu.schedule.ui.lesson
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -24,6 +24,7 @@ import androidx.compose.ui.unit.dp
|
||||
import org.xtimms.psu.schedule.data.DayPlace
|
||||
import org.xtimms.psu.schedule.data.Timetable
|
||||
import java.time.LocalDate
|
||||
import org.xtimms.psu.schedule.ui.MainViewModel
|
||||
|
||||
// "Занятия в Полоцке": Word marks the days a group spends in another town over the day's first
|
||||
// row. The server reads it as a note of the day, not a lesson, and gives each lesson its town.
|
||||
@@ -47,7 +48,7 @@ fun DayPlaces(places: List<DayPlace>, modifier: Modifier = Modifier) {
|
||||
Icon(Icons.Rounded.LocationCity, contentDescription = null, modifier = Modifier.size(24.dp))
|
||||
Spacer(Modifier.width(14.dp))
|
||||
Column {
|
||||
Text(p.text, style = MaterialTheme.typography.titleMediumEmphasized)
|
||||
Text(p.label, style = MaterialTheme.typography.titleMediumEmphasized)
|
||||
Text(
|
||||
listOfNotNull(p.subgroup, "пометка в Word на этот день").joinToString(" · ").replaceFirstChar { it.titlecase() },
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
@@ -67,7 +68,7 @@ fun DayPlaceMark(places: List<DayPlace>, modifier: Modifier = Modifier) {
|
||||
shape = MaterialTheme.shapes.extraSmall,
|
||||
color = MaterialTheme.colorScheme.tertiaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
modifier = modifier.semantics { contentDescription = places.joinToString(", ") { listOfNotNull(it.text, it.subgroup).joinToString(", ") } },
|
||||
modifier = modifier.semantics { contentDescription = places.joinToString(", ") { listOfNotNull(it.label, it.subgroup).joinToString(", ") } },
|
||||
) {
|
||||
Text(
|
||||
first.place,
|
||||
@@ -1,4 +1,4 @@
|
||||
package org.xtimms.psu.schedule.ui
|
||||
package org.xtimms.psu.schedule.ui.lesson
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -0,0 +1,38 @@
|
||||
package org.xtimms.psu.schedule.ui.lesson
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import kotlinx.coroutines.delay
|
||||
import org.xtimms.psu.schedule.data.Lesson
|
||||
import org.xtimms.psu.schedule.data.Timetable
|
||||
import org.xtimms.psu.schedule.data.nowTime
|
||||
import org.xtimms.psu.schedule.data.parseTime
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalTime
|
||||
import org.xtimms.psu.schedule.ui.MainViewModel
|
||||
|
||||
/** One time slot of a day: parallel lessons of subgroups share it. */
|
||||
data class Slot(val date: LocalDate, val start: LocalTime, val end: LocalTime, val lessons: List<Lesson>)
|
||||
|
||||
fun MainViewModel.slotsOn(t: Timetable, date: LocalDate, subgroup: String?): List<Slot> =
|
||||
repository.lessonsOn(t, date, subgroup, hidden)
|
||||
.groupBy { it.start }
|
||||
.map { (start, lessons) -> Slot(date, parseTime(start), lessonEnd(lessons.first(), t), lessons) }
|
||||
.sortedBy { it.start }
|
||||
|
||||
/** The current time, refreshed every half minute while the screen is shown. */
|
||||
@Composable
|
||||
fun rememberClock(): LocalTime {
|
||||
var clock by remember { mutableStateOf(nowTime()) }
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
delay(30_000)
|
||||
clock = nowTime()
|
||||
}
|
||||
}
|
||||
return clock
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package org.xtimms.psu.schedule.ui
|
||||
package org.xtimms.psu.schedule.ui.lesson
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -11,6 +11,13 @@ import org.xtimms.psu.schedule.data.Lesson
|
||||
import org.xtimms.psu.schedule.data.Timetable
|
||||
import org.xtimms.psu.schedule.data.parseTime
|
||||
import java.time.LocalTime
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
/**
|
||||
* A subject's colours: [container]/[content] for small strong fills (week grid, dots),
|
||||
@@ -117,3 +124,9 @@ fun lessonEnd(lesson: Lesson, t: Timetable): LocalTime =
|
||||
?: parseTime(lesson.start).plusMinutes(80)
|
||||
|
||||
fun Color.luminance(): Float = 0.2126f * red + 0.7152f * green + 0.0722f * blue
|
||||
|
||||
@Composable
|
||||
fun SubjectDot(subject: String, size: Dp = 12.dp, modifier: Modifier = Modifier) {
|
||||
Box(modifier.size(size).background(subjectColor(subject).container, CircleShape))
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package org.xtimms.psu.schedule.ui
|
||||
package org.xtimms.psu.schedule.ui.motion
|
||||
|
||||
import androidx.activity.compose.PredictiveBackHandler
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.ui.graphics.drawscope.translate
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.animation.core.animate
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
@@ -40,6 +43,7 @@ import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.geometry.lerp
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.TransformOrigin
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.layer.GraphicsLayer
|
||||
import androidx.compose.ui.graphics.layer.drawLayer
|
||||
@@ -66,8 +70,14 @@ import kotlin.math.roundToInt
|
||||
// new plan, and back. Bottom sheets live in windows of their own and cannot grow from a card, so
|
||||
// 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)
|
||||
/** Where a transform starts: an element's bounds, its fill, corners and shadow and a picture of it. */
|
||||
class MorphOrigin(val bounds: Rect, val color: Color, val radius: Dp, val elevation: Dp, val picture: GraphicsLayer)
|
||||
|
||||
/**
|
||||
* The inside of an origin, without the origin's own fill and shadow: the container draws those
|
||||
* itself, so a button's shape and shadow are never seen twice while it grows ([morphContent]).
|
||||
*/
|
||||
class MorphContent(val bounds: Rect, val picture: GraphicsLayer)
|
||||
|
||||
enum class MorphKind {
|
||||
/** A page: the whole screen on a phone, a large panel on a tablet. */
|
||||
@@ -82,6 +92,7 @@ class MorphEntry(val key: String?, val kind: MorphKind, val content: @Composable
|
||||
@Stable
|
||||
class MorphHost {
|
||||
internal val origins = mutableMapOf<String, MorphOrigin>()
|
||||
internal val contents = mutableMapOf<String, MorphContent>()
|
||||
// The element the finger went down on, and when: the page grows from what was tapped.
|
||||
internal var touched: String? = null
|
||||
internal var touchedAt = 0L
|
||||
@@ -90,11 +101,16 @@ class MorphHost {
|
||||
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 }
|
||||
/** The element tapped a moment ago, if any. */
|
||||
fun recentTouch(): String? = touched?.takeIf { System.currentTimeMillis() - touchedAt < 1500 }
|
||||
|
||||
/**
|
||||
* Opens [content] growing out of [from] (an origin's key) or, by default, out of the element
|
||||
* tapped a moment ago, if any.
|
||||
*/
|
||||
fun open(kind: MorphKind, from: String? = recentTouch(), content: @Composable (close: () -> Unit) -> Unit) {
|
||||
closing = false
|
||||
entry = MorphEntry(key, kind, content)
|
||||
entry = MorphEntry(from, kind, content)
|
||||
}
|
||||
|
||||
fun close() {
|
||||
@@ -105,17 +121,18 @@ class MorphHost {
|
||||
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.
|
||||
* Marks a card or a button as a place a page or a sheet grows from: its fill [color], corner
|
||||
* [radius] and shadow [elevation] are the container's at the start. While its page is open the
|
||||
* element itself is hidden, shadow and all: the page has taken its place.
|
||||
*/
|
||||
@Composable
|
||||
fun Modifier.morphOrigin(key: String, color: Color, radius: Dp): Modifier {
|
||||
fun Modifier.morphOrigin(key: String, color: Color, radius: Dp, elevation: Dp = 0.dp): Modifier {
|
||||
val host = LocalMorph.current ?: return this
|
||||
val picture = rememberGraphicsLayer()
|
||||
DisposableEffect(host, key) { onDispose { host.origins.remove(key) } }
|
||||
val hidden = host.entry?.key == key
|
||||
return this
|
||||
.onGloballyPositioned { host.origins[key] = MorphOrigin(it.boundsInRoot(), color, radius, picture) }
|
||||
.onGloballyPositioned { host.origins[key] = MorphOrigin(it.boundsInRoot(), color, radius, elevation, picture) }
|
||||
.pointerInput(host, key) {
|
||||
awaitEachGesture {
|
||||
awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
|
||||
@@ -130,9 +147,30 @@ fun Modifier.morphOrigin(key: String, color: Color, radius: Dp): Modifier {
|
||||
}
|
||||
|
||||
/**
|
||||
* The open page or sheet over the app. Opening, it grows from its origin (or from the middle):
|
||||
* 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.
|
||||
* Marks what is inside an origin with the same [key] (a button's icon, a card's text): the
|
||||
* container draws this picture instead of the whole element, on its own fill and shadow.
|
||||
*/
|
||||
@Composable
|
||||
fun Modifier.morphContent(key: String): Modifier {
|
||||
val host = LocalMorph.current ?: return this
|
||||
val picture = rememberGraphicsLayer()
|
||||
DisposableEffect(host, key) { onDispose { host.contents.remove(key) } }
|
||||
return this
|
||||
.onGloballyPositioned { host.contents[key] = MorphContent(it.boundsInRoot(), picture) }
|
||||
.drawWithContent {
|
||||
picture.record { this@drawWithContent.drawContent() }
|
||||
drawLayer(picture)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The open page or sheet over the app, as the M3 container transform (MDC's
|
||||
* MaterialContainerTransform). Opening, the container grows from its origin (or from the middle),
|
||||
* and both contents ride in it, scaled to its width: the card's picture underneath, the page
|
||||
* laid out at its final size on top, fading in over the first quarter while the corners
|
||||
* straighten over three quarters. Back, a tap on the scrim, the predictive back gesture or the
|
||||
* sheet's handle shrink it into the card again, the page fading out late, between 60 and 90 %
|
||||
* of the way.
|
||||
*/
|
||||
@Composable
|
||||
fun MorphOverlay(host: MorphHost) {
|
||||
@@ -150,7 +188,11 @@ fun MorphOverlay(host: MorphHost) {
|
||||
}
|
||||
LaunchedEffect(host.closing) {
|
||||
if (!host.closing) return@LaunchedEffect
|
||||
host.progress.animateTo(0f, tween(if (reduced) 0 else 350, easing = Emphasized))
|
||||
val spec = tween<Float>(if (reduced) 0 else 350, easing = Emphasized)
|
||||
// A sheet dragged down or a page shrunk by the back gesture still lands on its origin.
|
||||
launch { animate(drag, 0f, animationSpec = spec) { v, _ -> drag = v } }
|
||||
launch { animate(back, 0f, animationSpec = spec) { v, _ -> back = v } }
|
||||
host.progress.animateTo(0f, spec)
|
||||
host.entry = null
|
||||
host.closing = false
|
||||
}
|
||||
@@ -183,6 +225,7 @@ fun MorphOverlay(host: MorphHost) {
|
||||
}
|
||||
}
|
||||
val origin = entry.key?.let { host.origins[it] }
|
||||
val inside = entry.key?.let { host.contents[it] }
|
||||
val from = origin?.bounds?.translate(-rootOffset) ?: Rect(target.center, 0f).inflate(minOf(target.width, target.height) * 0.3f)
|
||||
val p = host.progress.value
|
||||
val rect = lerp(from, target, p)
|
||||
@@ -191,9 +234,16 @@ fun MorphOverlay(host: MorphHost) {
|
||||
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))
|
||||
// The thresholds of MDC's container transform, as fractions of this way (p) and back.
|
||||
val closing = host.closing
|
||||
val fade = (if (closing) (p - 0.1f) / 0.3f else p / 0.25f).coerceIn(0f, 1f)
|
||||
val corners = (if (closing) (p - 0.1f) / 0.6f else p / 0.75f).coerceIn(0f, 1f)
|
||||
val startRadius = origin?.radius ?: 28.dp
|
||||
val radius = lerp(startRadius, targetRadius, corners)
|
||||
val shape = if (entry.kind == MorphKind.Sheet) RoundedCornerShape(radius, radius, lerp(startRadius, 0.dp, corners), lerp(startRadius, 0.dp, corners))
|
||||
else RoundedCornerShape(radius)
|
||||
// Both contents are scaled to the container's width and move with it.
|
||||
val pageScale = rect.width / target.width.coerceAtLeast(1f)
|
||||
// The scrim; a tap on it closes.
|
||||
Box(
|
||||
Modifier
|
||||
@@ -203,22 +253,35 @@ fun MorphOverlay(host: MorphHost) {
|
||||
)
|
||||
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) }) {
|
||||
// The card's own picture underneath, growing with the container.
|
||||
if (origin != null) Box(
|
||||
Modifier.drawBehind {
|
||||
val s = rect.width / origin.bounds.width.coerceAtLeast(1f)
|
||||
scale(s, s, pivot = Offset.Zero) {
|
||||
// Just the inside where it is marked: the fill and shadow are the container's.
|
||||
if (inside != null) translate(inside.bounds.left - origin.bounds.left, inside.bounds.top - origin.bounds.top) {
|
||||
drawLayer(inside.picture)
|
||||
} else drawLayer(origin.picture)
|
||||
}
|
||||
},
|
||||
)
|
||||
// The page, laid out once at its final size and shrunk to the container's width
|
||||
// from its top left corner: it comes out of the card instead of being uncovered.
|
||||
Box(
|
||||
Modifier
|
||||
.graphicsLayer {
|
||||
alpha = fade
|
||||
scaleX = pageScale
|
||||
scaleY = pageScale
|
||||
transformOrigin = TransformOrigin(0f, 0f)
|
||||
}
|
||||
.background(surface),
|
||||
) {
|
||||
if (entry.kind == MorphKind.Sheet) SheetChrome(onDrag = { drag = (drag + it).coerceAtLeast(0f) }, onRelease = {
|
||||
if (drag > with(density) { 120.dp.toPx() }) host.close() else drag = 0f
|
||||
}) { entry.content(host::close) }
|
||||
else entry.content(host::close)
|
||||
}
|
||||
// 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()) }
|
||||
@@ -228,16 +291,18 @@ fun MorphOverlay(host: MorphHost) {
|
||||
scaleY = s
|
||||
translationY = drag
|
||||
}
|
||||
// The origin's shadow (a button's), fading out as it grows and back as it lands.
|
||||
.shadow(lerp(origin?.elevation ?: 0.dp, 0.dp, p), shape, clip = false)
|
||||
.clip(shape)
|
||||
.background(lerp(origin?.color ?: surface, surface, (p / 0.5f).coerceIn(0f, 1f)))
|
||||
.background(lerp(origin?.color ?: surface, surface, fade))
|
||||
// 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)))
|
||||
val picture = if (origin != null) measurables[0].measure(Constraints.fixed(rect.width.roundToInt().coerceAtLeast(0), rect.height.roundToInt().coerceAtLeast(0))) else null
|
||||
val content = measurables.last().measure(Constraints.fixed(target.width.roundToInt(), target.height.roundToInt()))
|
||||
layout(rect.width.roundToInt().coerceAtLeast(0), rect.height.roundToInt().coerceAtLeast(0)) {
|
||||
content.place((target.left - rect.left).roundToInt(), (target.top - rect.top).roundToInt())
|
||||
picture?.place(0, 0)
|
||||
content.place(0, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package org.xtimms.psu.schedule.ui
|
||||
package org.xtimms.psu.schedule.ui.motion
|
||||
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -1,6 +1,6 @@
|
||||
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||
|
||||
package org.xtimms.psu.schedule.ui
|
||||
package org.xtimms.psu.schedule.ui.notes
|
||||
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -73,6 +73,20 @@ import org.xtimms.psu.schedule.data.shortWeekday
|
||||
import org.xtimms.psu.schedule.data.today
|
||||
import org.xtimms.psu.schedule.data.weekdayName
|
||||
import java.time.LocalDate
|
||||
import org.xtimms.psu.schedule.ui.MainViewModel
|
||||
import org.xtimms.psu.schedule.ui.components.ConnectedChoice
|
||||
import org.xtimms.psu.schedule.ui.components.DONE_PAUSE
|
||||
import org.xtimms.psu.schedule.ui.components.Heading
|
||||
import org.xtimms.psu.schedule.ui.components.LocalUndo
|
||||
import org.xtimms.psu.schedule.ui.components.StrikeText
|
||||
import org.xtimms.psu.schedule.ui.lesson.SubjectDot
|
||||
import org.xtimms.psu.schedule.ui.lesson.subjectKey
|
||||
import org.xtimms.psu.schedule.ui.components.SwipeAction
|
||||
import org.xtimms.psu.schedule.ui.components.SwipeActions
|
||||
import org.xtimms.psu.schedule.ui.components.UndoHost
|
||||
import androidx.compose.material.icons.rounded.Check
|
||||
import androidx.compose.material.icons.automirrored.rounded.Undo
|
||||
import androidx.compose.material.icons.rounded.EventRepeat
|
||||
|
||||
/** Tasks and notes of the open group, for cards deep in the tree. */
|
||||
val LocalNotes = staticCompositionLocalOf<List<Note>> { emptyList() }
|
||||
@@ -118,15 +132,48 @@ fun MainViewModel.nextOccurrence(t: Timetable, subject: String, after: LocalDate
|
||||
.firstOrNull()
|
||||
}
|
||||
|
||||
/** A task with its checkbox or a note with its icon; tap opens the editor. */
|
||||
/**
|
||||
* A task with its checkbox or a note with its icon; tap opens the editor. Swiped to the right a
|
||||
* task is done (or open again); swiped to the left it moves to the next lesson of its subject with
|
||||
* [onPostpone]. [swipe] is off where a sideways swipe pages, as on the plans board.
|
||||
*/
|
||||
@Composable
|
||||
fun NoteRow(note: Note, onToggle: () -> Unit, onClick: () -> Unit, showSubject: Boolean, showDue: Boolean = true) {
|
||||
fun NoteRow(
|
||||
note: Note,
|
||||
onToggle: () -> Unit,
|
||||
onClick: () -> Unit,
|
||||
showSubject: Boolean,
|
||||
showDue: Boolean = true,
|
||||
onPostpone: ((UndoHost?) -> Unit)? = null,
|
||||
swipe: Boolean = true,
|
||||
) {
|
||||
val overdue = note.overdue()
|
||||
var viewing by remember { mutableStateOf(false) }
|
||||
val haptic = LocalHapticFeedback.current
|
||||
// A ticked task is struck through in place first, then moves down to the done ones.
|
||||
var shownDone by remember(note.id, note.done) { mutableStateOf(note.done) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val undo = LocalUndo.current
|
||||
fun check(done: Boolean) {
|
||||
haptic.performHapticFeedback(if (done) HapticFeedbackType.Confirm else HapticFeedbackType.ToggleOff)
|
||||
shownDone = done
|
||||
if (done) scope.launch {
|
||||
delay(DONE_PAUSE)
|
||||
onToggle()
|
||||
} else onToggle()
|
||||
}
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val doneAction = if (!swipe || !note.task) null
|
||||
else if (!shownDone) SwipeAction(Icons.Rounded.Check, "Выполнено", scheme.primary, scheme.onPrimary) { check(true) }
|
||||
else SwipeAction(Icons.AutoMirrored.Rounded.Undo, "Не выполнено", scheme.secondary, scheme.onSecondary) { check(false) }
|
||||
val laterAction = if (!swipe || onPostpone == null || (note.task && shownDone)) null
|
||||
else SwipeAction(
|
||||
Icons.Rounded.EventRepeat,
|
||||
if (note.general) "Перенести на завтра" else "Перенести к следующей паре",
|
||||
scheme.tertiary,
|
||||
scheme.onTertiary,
|
||||
) { onPostpone(undo) }
|
||||
SwipeActions(doneAction, laterAction) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
@@ -135,14 +182,7 @@ fun NoteRow(note: Note, onToggle: () -> Unit, onClick: () -> Unit, showSubject:
|
||||
.clickable(onClickLabel = "Изменить", role = Role.Button, onClick = onClick)
|
||||
.padding(start = 8.dp, end = 16.dp, top = 2.dp, bottom = 2.dp),
|
||||
) {
|
||||
if (note.task) Checkbox(checked = shownDone, onCheckedChange = { done ->
|
||||
haptic.performHapticFeedback(if (done) HapticFeedbackType.Confirm else HapticFeedbackType.ToggleOff)
|
||||
shownDone = done
|
||||
if (done) scope.launch {
|
||||
delay(DONE_PAUSE)
|
||||
onToggle()
|
||||
} else onToggle()
|
||||
})
|
||||
if (note.task) Checkbox(checked = shownDone, onCheckedChange = ::check)
|
||||
else Box(Modifier.size(48.dp), contentAlignment = Alignment.Center) {
|
||||
Icon(Icons.Rounded.StickyNote2, contentDescription = "Заметка", tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
@@ -173,19 +213,10 @@ fun NoteRow(note: Note, onToggle: () -> Unit, onClick: () -> Unit, showSubject:
|
||||
}
|
||||
// The first photo, with how many more; it opens the viewer, the row the editor.
|
||||
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),
|
||||
)
|
||||
PhotoThumb(note.photos.first(), 48.dp, onClick = { viewing = true }, more = note.photos.size - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (viewing) PhotoViewer(note.photos, 0) { viewing = false }
|
||||
}
|
||||
|
||||
@@ -350,7 +381,7 @@ fun LessonNotes(vm: MainViewModel, lesson: Lesson, date: LocalDate) {
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
notes.forEach { note ->
|
||||
NoteRow(note, onToggle = { vm.toggleNote(note.id) }, onClick = { editing = note }, showSubject = false)
|
||||
NoteRow(note, onToggle = { vm.toggleNote(note.id) }, onClick = { editing = note }, showSubject = false, onPostpone = { vm.postponeNote(note, it) })
|
||||
}
|
||||
FilledTonalButton(onClick = { adding = true }, modifier = Modifier.padding(top = 8.dp)) {
|
||||
Icon(Icons.Rounded.Add, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
@@ -0,0 +1,472 @@
|
||||
package org.xtimms.psu.schedule.ui.notes
|
||||
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import android.util.LruCache
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.PickVisualMediaRequest
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.gestures.calculatePan
|
||||
import androidx.compose.foundation.gestures.calculateZoom
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Close
|
||||
import androidx.compose.material.icons.rounded.PhotoCamera
|
||||
import androidx.compose.material.icons.rounded.PhotoLibrary
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.FilledIconButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.input.pointer.positionChanged
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.core.content.FileProvider
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.xtimms.psu.schedule.app
|
||||
import java.io.File
|
||||
import androidx.activity.compose.PredictiveBackHandler
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.animate
|
||||
import androidx.compose.animation.core.snap
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.geometry.lerp
|
||||
import androidx.compose.ui.layout.boundsInRoot
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.window.DialogWindowProvider
|
||||
import androidx.core.view.WindowCompat
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import org.xtimms.psu.schedule.ui.motion.Emphasized
|
||||
import org.xtimms.psu.schedule.ui.motion.rememberReducedMotion
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
// Photos of the board or the assignment, pinned to a task or a note (NotesStore keeps the files).
|
||||
|
||||
/** Decoded thumbnails, so lists scrolling past the same photos do not decode them again. */
|
||||
private val thumbnails = object : LruCache<String, ImageBitmap>(24 * 1024 * 1024) {
|
||||
override fun sizeOf(key: String, value: ImageBitmap) = value.width * value.height * 4
|
||||
}
|
||||
|
||||
/** A photo decoded no larger than [max] pixels on its long side; null while it loads. */
|
||||
@Composable
|
||||
private fun rememberPhoto(file: File, max: Int): ImageBitmap? {
|
||||
val key = "${file.name}@$max"
|
||||
return produceState(thumbnails.get(key), key) {
|
||||
// The state outlives a change of photo: never keep showing the previous one.
|
||||
thumbnails.get(key)?.let {
|
||||
value = it
|
||||
return@produceState
|
||||
}
|
||||
value = null
|
||||
value = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeFile(file.path, bounds)
|
||||
var sample = 1
|
||||
while (maxOf(bounds.outWidth, bounds.outHeight) / (sample * 2) >= max) sample *= 2
|
||||
BitmapFactory.decodeFile(file.path, BitmapFactory.Options().apply { inSampleSize = sample })?.asImageBitmap()
|
||||
}.getOrNull()
|
||||
}?.also { thumbnails.put(key, it) }
|
||||
}.value
|
||||
}
|
||||
|
||||
/**
|
||||
* Where each photo's thumbnail is on the screen, so the viewer grows out of it and shrinks back
|
||||
* into it (the M3 container transform for a hero moment). Screen coordinates, since a thumbnail
|
||||
* may sit in a dialog (the editor) and the viewer is a window of its own.
|
||||
*/
|
||||
private object PhotoHero {
|
||||
val bounds = mutableMapOf<String, Rect>()
|
||||
|
||||
/** The photo the viewer has taken over: its thumbnail steps aside meanwhile. */
|
||||
var hidden by mutableStateOf<String?>(null)
|
||||
}
|
||||
|
||||
/** A square thumbnail; [onClick] opens the viewer. [more] photos behind it show as "+2". */
|
||||
@Composable
|
||||
fun PhotoThumb(name: String, size: Dp, onClick: (() -> Unit)?, modifier: Modifier = Modifier, more: Int = 0) {
|
||||
val store = LocalContext.current.app.notes
|
||||
val px = with(LocalDensity.current) { size.roundToPx() } * 2
|
||||
val image = rememberPhoto(store.photo(name), px)
|
||||
val view = LocalView.current
|
||||
val placed = remember { mutableStateOf<Rect?>(null) }
|
||||
DisposableEffect(name) {
|
||||
onDispose { if (PhotoHero.bounds[name] == placed.value) PhotoHero.bounds.remove(name) }
|
||||
}
|
||||
Box(
|
||||
modifier
|
||||
.size(size)
|
||||
.onGloballyPositioned { c ->
|
||||
val at = IntArray(2).also(view::getLocationOnScreen)
|
||||
val rect = c.boundsInRoot().translate(at[0].toFloat(), at[1].toFloat())
|
||||
placed.value = rect
|
||||
PhotoHero.bounds[name] = rect
|
||||
}
|
||||
.graphicsLayer { alpha = if (PhotoHero.hidden == name) 0f else 1f }
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
|
||||
.then(if (onClick != null) Modifier.clickable(onClickLabel = "Открыть фото", onClick = onClick) else Modifier),
|
||||
) {
|
||||
image?.let { Image(it, contentDescription = "Фото", contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize()) }
|
||||
if (more > 0) Text(
|
||||
"+$more",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.inverseOnSurface,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(3.dp)
|
||||
.background(MaterialTheme.colorScheme.inverseSurface.copy(alpha = 0.85f), MaterialTheme.shapes.small)
|
||||
.padding(horizontal = 5.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The editor's photos: thumbnails with a remove button, then "Снять" (the camera app) and
|
||||
* "Галерея" (the system photo picker, no storage permission needed).
|
||||
*/
|
||||
@Composable
|
||||
fun NotePhotosEditor(photos: List<String>, onAdd: (String) -> Unit, onRemove: (String) -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val store = context.app.notes
|
||||
val scope = rememberCoroutineScope()
|
||||
var loading by remember { mutableIntStateOf(0) }
|
||||
var viewing by remember { mutableStateOf<Int?>(null) }
|
||||
fun import(uris: List<Uri>) {
|
||||
uris.forEach { uri ->
|
||||
loading++
|
||||
scope.launch {
|
||||
store.importPhoto(uri)?.let(onAdd)
|
||||
loading--
|
||||
}
|
||||
}
|
||||
}
|
||||
// The camera app writes the full-size shot here; the store keeps a smaller copy.
|
||||
val shot = remember { File(context.cacheDir, "camera/shot.jpg") }
|
||||
val camera = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { taken ->
|
||||
if (taken) {
|
||||
loading++
|
||||
scope.launch {
|
||||
store.importPhoto(Uri.fromFile(shot))?.let(onAdd)
|
||||
shot.delete()
|
||||
loading--
|
||||
}
|
||||
}
|
||||
}
|
||||
val gallery = rememberLauncherForActivityResult(ActivityResultContracts.PickMultipleVisualMedia(MAX_PHOTOS)) { import(it) }
|
||||
val room = MAX_PHOTOS - photos.size
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
if (photos.isNotEmpty() || loading > 0) Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
||||
) {
|
||||
photos.forEachIndexed { index, name ->
|
||||
Box {
|
||||
PhotoThumb(name, 72.dp, onClick = { viewing = index })
|
||||
FilledIconButton(
|
||||
onClick = { onRemove(name) },
|
||||
colors = IconButtonDefaults.filledIconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.inverseSurface,
|
||||
contentColor = MaterialTheme.colorScheme.inverseOnSurface,
|
||||
),
|
||||
modifier = Modifier.align(Alignment.TopEnd).padding(4.dp).size(24.dp),
|
||||
) { Icon(Icons.Rounded.Close, contentDescription = "Убрать фото", modifier = Modifier.size(16.dp)) }
|
||||
}
|
||||
}
|
||||
repeat(loading) {
|
||||
Box(
|
||||
Modifier.size(72.dp).clip(MaterialTheme.shapes.medium).background(MaterialTheme.colorScheme.surfaceContainerHighest),
|
||||
contentAlignment = Alignment.Center,
|
||||
) { CircularProgressIndicator(Modifier.size(24.dp), strokeWidth = 2.dp) }
|
||||
}
|
||||
}
|
||||
if (room > 0) Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
shot.parentFile?.mkdirs()
|
||||
val uri = FileProvider.getUriForFile(context, context.packageName + ".photos", shot)
|
||||
// A phone without a camera app: the gallery is still there.
|
||||
runCatching { camera.launch(uri) }
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Icon(Icons.Rounded.PhotoCamera, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Снять", maxLines = 1)
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { gallery.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) },
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Icon(Icons.Rounded.PhotoLibrary, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Галерея", maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
viewing?.let { PhotoViewer(photos, it) { viewing = null } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Photos full screen, swiped sideways; pinch or double-tap to zoom into the board. It opens as a
|
||||
* container transform from the thumbnail that was tapped: the photo grows from its square crop
|
||||
* into the whole picture, its corners straighten and the black comes up around it. Closing
|
||||
* shrinks the photo shown back into its thumbnail, or fades it away when that one is not there.
|
||||
*/
|
||||
@Composable
|
||||
fun PhotoViewer(photos: List<String>, start: Int, onDismiss: () -> Unit) {
|
||||
if (photos.isEmpty()) return
|
||||
var closing by remember { mutableStateOf(false) }
|
||||
Dialog(
|
||||
onDismissRequest = { closing = true },
|
||||
// Edge to edge and with no dim of its own: the viewer draws its own black.
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false),
|
||||
) {
|
||||
val view = LocalView.current
|
||||
(view.parent as? DialogWindowProvider)?.window?.let { window ->
|
||||
SideEffect {
|
||||
window.setDimAmount(0f)
|
||||
// The transform is the whole animation: no window fade on top of it.
|
||||
window.setWindowAnimations(0)
|
||||
// Light status bar icons over the black.
|
||||
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = false
|
||||
}
|
||||
}
|
||||
PhotoPages(photos, start, closing, onClose = { closing = true }, onClosed = onDismiss)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PhotoPages(photos: List<String>, start: Int, closing: Boolean, onClose: () -> Unit, onClosed: () -> Unit) {
|
||||
val store = LocalContext.current.app.notes
|
||||
val reduced = rememberReducedMotion()
|
||||
val pager = rememberPagerState(initialPage = start.coerceIn(0, photos.lastIndex)) { photos.size }
|
||||
val name = photos[pager.currentPage]
|
||||
val image = rememberPhoto(store.photo(name), 2048)
|
||||
// The thumbnail the photo shown returns to: its own, or the one the viewer opened from (a
|
||||
// task's row shows only its first photo).
|
||||
val anchor = if (PhotoHero.bounds.containsKey(name)) name else photos[start.coerceIn(0, photos.lastIndex)]
|
||||
// Returning to another photo's thumbnail, the photo turns into that one on the last stretch.
|
||||
val anchorImage = if (anchor != name && closing) rememberPhoto(store.photo(anchor), 1024) else null
|
||||
val progress = remember { Animatable(0f) }
|
||||
var back by remember { mutableFloatStateOf(0f) }
|
||||
var opened by remember { mutableStateOf(false) }
|
||||
var gone by remember { mutableStateOf(false) }
|
||||
val view = LocalView.current
|
||||
val scope = rememberCoroutineScope()
|
||||
// Opens once the photo is decoded, so it grows as itself and not as an empty box.
|
||||
LaunchedEffect(image != null) {
|
||||
if (image == null || opened) return@LaunchedEffect
|
||||
PhotoHero.hidden = name
|
||||
progress.animateTo(1f, if (reduced) snap() else tween(450, easing = Emphasized))
|
||||
opened = true
|
||||
PhotoHero.hidden = null
|
||||
}
|
||||
LaunchedEffect(closing) {
|
||||
if (!closing) return@LaunchedEffect
|
||||
PhotoHero.hidden = anchor
|
||||
progress.animateTo(0f, if (reduced) snap() else tween(350, easing = Emphasized))
|
||||
// The thumbnail takes over again before the window goes.
|
||||
gone = true
|
||||
PhotoHero.hidden = null
|
||||
withFrameNanos {}
|
||||
onClosed()
|
||||
}
|
||||
PredictiveBackHandler(enabled = !closing) { events ->
|
||||
try {
|
||||
events.collect { back = it.progress }
|
||||
onClose()
|
||||
} catch (e: CancellationException) {
|
||||
scope.launch { animate(back, 0f) { v, _ -> back = v } }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||
val w = constraints.maxWidth.toFloat()
|
||||
val h = constraints.maxHeight.toFloat()
|
||||
val p = progress.value
|
||||
Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = p)))
|
||||
val hero = !opened || closing
|
||||
if (hero && image != null && !gone) {
|
||||
// The whole photo on the screen, and the thumbnail it grows from, in this window.
|
||||
val fit = minOf(w / image.width, h / image.height)
|
||||
val target = Rect(Offset((w - image.width * fit) / 2, (h - image.height * fit) / 2), Size(image.width * fit, image.height * fit))
|
||||
val at = IntArray(2).also(view::getLocationOnScreen)
|
||||
val from = PhotoHero.bounds[anchor]?.translate(-at[0].toFloat(), -at[1].toFloat())
|
||||
val rect = if (from != null) lerp(from, target, p) else target
|
||||
val corner = with(LocalDensity.current) { (12.dp * (1f - p)).toPx() }
|
||||
Image(
|
||||
image,
|
||||
contentDescription = null,
|
||||
// A square crop at the thumbnail, the whole photo on the screen.
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.offset { IntOffset(rect.left.roundToInt(), rect.top.roundToInt()) }
|
||||
.size(with(LocalDensity.current) { rect.width.toDp() }, with(LocalDensity.current) { rect.height.toDp() })
|
||||
.graphicsLayer {
|
||||
// With no thumbnail to return to, it fades and settles a little smaller.
|
||||
if (from == null) {
|
||||
alpha = p
|
||||
val s = 0.9f + 0.1f * p
|
||||
scaleX = s
|
||||
scaleY = s
|
||||
}
|
||||
shape = RoundedCornerShape(corner)
|
||||
clip = true
|
||||
},
|
||||
)
|
||||
if (from != null && anchorImage != null) Image(
|
||||
anchorImage,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.offset { IntOffset(rect.left.roundToInt(), rect.top.roundToInt()) }
|
||||
.size(with(LocalDensity.current) { rect.width.toDp() }, with(LocalDensity.current) { rect.height.toDp() })
|
||||
.graphicsLayer {
|
||||
alpha = (1f - p / 0.35f).coerceIn(0f, 1f)
|
||||
shape = RoundedCornerShape(corner)
|
||||
clip = true
|
||||
},
|
||||
)
|
||||
}
|
||||
if (!hero) Box(
|
||||
Modifier.fillMaxSize().graphicsLayer {
|
||||
// The predictive back gesture shrinks the photo a little before it lets go.
|
||||
val s = 1f - 0.1f * back
|
||||
scaleX = s
|
||||
scaleY = s
|
||||
},
|
||||
) {
|
||||
HorizontalPager(state = pager, modifier = Modifier.fillMaxSize()) { page ->
|
||||
ZoomablePhoto(photos[page], page, photos.size)
|
||||
}
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.graphicsLayer { alpha = ((p - 0.5f) / 0.5f).coerceIn(0f, 1f) }
|
||||
.statusBarsPadding()
|
||||
.padding(8.dp),
|
||||
) {
|
||||
IconButton(onClick = onClose) { Icon(Icons.Rounded.Close, contentDescription = "Закрыть", tint = Color.White) }
|
||||
Spacer(Modifier.weight(1f))
|
||||
if (photos.size > 1) Surface(shape = CircleShape, color = Color.Black.copy(alpha = 0.5f), contentColor = Color.White) {
|
||||
Text("${pager.currentPage + 1} / ${photos.size}", style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One photo of the viewer: two fingers zoom, a double tap zooms in and out. */
|
||||
@Composable
|
||||
private fun ZoomablePhoto(name: String, page: Int, count: Int) {
|
||||
val store = LocalContext.current.app.notes
|
||||
var scale by remember { mutableFloatStateOf(1f) }
|
||||
var offset by remember { mutableStateOf(Offset.Zero) }
|
||||
val image = rememberPhoto(store.photo(name), 2048)
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(onDoubleTap = {
|
||||
if (scale > 1f) { scale = 1f; offset = Offset.Zero } else scale = 2.5f
|
||||
})
|
||||
}
|
||||
.pointerInput(Unit) {
|
||||
// Two fingers zoom; one finger pans only while zoomed in, so at 1x the
|
||||
// pager still takes the swipe to the next photo.
|
||||
awaitEachGesture {
|
||||
awaitFirstDown(requireUnconsumed = false)
|
||||
do {
|
||||
val event = awaitPointerEvent()
|
||||
if (event.changes.size > 1 || scale > 1f) {
|
||||
scale = (scale * event.calculateZoom()).coerceIn(1f, 5f)
|
||||
offset = if (scale > 1f) offset + event.calculatePan() else Offset.Zero
|
||||
event.changes.forEach { if (it.positionChanged()) it.consume() }
|
||||
}
|
||||
} while (event.changes.any { it.pressed })
|
||||
}
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (image == null) CircularProgressIndicator(color = Color.White)
|
||||
else Image(
|
||||
image,
|
||||
contentDescription = "Фото ${page + 1} из $count",
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier.fillMaxSize().graphicsLayer {
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
translationX = offset.x
|
||||
translationY = offset.y
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Up to this many photos per task or note. */
|
||||
const val MAX_PHOTOS = 6
|
||||
@@ -1,6 +1,6 @@
|
||||
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||
|
||||
package org.xtimms.psu.schedule.ui
|
||||
package org.xtimms.psu.schedule.ui.onboarding
|
||||
|
||||
import android.Manifest
|
||||
import android.os.Build
|
||||
@@ -93,6 +93,17 @@ import org.xtimms.psu.schedule.notify.Reminders
|
||||
import org.xtimms.psu.schedule.ui.theme.WeekColors
|
||||
import org.xtimms.psu.schedule.ui.theme.page
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import org.xtimms.psu.schedule.ui.MainViewModel
|
||||
import org.xtimms.psu.schedule.ui.components.ConnectedChoice
|
||||
import org.xtimms.psu.schedule.ui.components.Heading
|
||||
import org.xtimms.psu.schedule.ui.components.SegmentedItem
|
||||
import org.xtimms.psu.schedule.ui.components.SegmentedList
|
||||
import org.xtimms.psu.schedule.ui.components.navBarPadding
|
||||
import org.xtimms.psu.schedule.ui.groups.GroupsScreen
|
||||
import org.xtimms.psu.schedule.ui.groups.choiceText
|
||||
import org.xtimms.psu.schedule.ui.groups.foreignText
|
||||
import org.xtimms.psu.schedule.ui.motion.materialSharedAxisX
|
||||
import org.xtimms.psu.schedule.ui.motion.rememberSlideDistance
|
||||
|
||||
/** The first run, in the order a student needs it: what this is, their group, their subgroup, reminders. */
|
||||
private enum class Step { Welcome, Group, Subgroup, Choice, Foreign, Reminders }
|
||||
@@ -1,6 +1,6 @@
|
||||
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||
|
||||
package org.xtimms.psu.schedule.ui
|
||||
package org.xtimms.psu.schedule.ui.plans
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.ui.unit.Dp
|
||||
@@ -101,6 +101,44 @@ import java.time.LocalDate
|
||||
import java.time.YearMonth
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
import org.xtimms.psu.schedule.ui.AppNav
|
||||
import org.xtimms.psu.schedule.ui.MainViewModel
|
||||
import org.xtimms.psu.schedule.ui.MenuButton
|
||||
import org.xtimms.psu.schedule.ui.components.CenteredEmpty
|
||||
import org.xtimms.psu.schedule.ui.components.ContentColumn
|
||||
import org.xtimms.psu.schedule.ui.components.DONE_PAUSE
|
||||
import org.xtimms.psu.schedule.ui.components.DetailPane
|
||||
import org.xtimms.psu.schedule.ui.components.Empty
|
||||
import org.xtimms.psu.schedule.ui.components.Heading
|
||||
import org.xtimms.psu.schedule.ui.components.LocalPaneColor
|
||||
import org.xtimms.psu.schedule.ui.components.LocalUndo
|
||||
import org.xtimms.psu.schedule.ui.components.MAX_CONTENT
|
||||
import org.xtimms.psu.schedule.ui.components.PaneRow
|
||||
import org.xtimms.psu.schedule.ui.components.StrikeText
|
||||
import org.xtimms.psu.schedule.ui.components.TimeText
|
||||
import org.xtimms.psu.schedule.ui.components.navBarPadding
|
||||
import org.xtimms.psu.schedule.ui.components.twoPane
|
||||
import org.xtimms.psu.schedule.ui.components.weekTintOf
|
||||
import org.xtimms.psu.schedule.ui.lesson.DayPlaces
|
||||
import org.xtimms.psu.schedule.ui.lesson.SessionDot
|
||||
import org.xtimms.psu.schedule.ui.lesson.placesOn
|
||||
import org.xtimms.psu.schedule.ui.lesson.rememberLessonOpener
|
||||
import org.xtimms.psu.schedule.ui.lesson.session
|
||||
import org.xtimms.psu.schedule.ui.lesson.sessionColors
|
||||
import org.xtimms.psu.schedule.ui.lesson.slotsOn
|
||||
import org.xtimms.psu.schedule.ui.lesson.subjectColor
|
||||
import org.xtimms.psu.schedule.ui.motion.AnimatedItem
|
||||
import org.xtimms.psu.schedule.ui.motion.Emphasized
|
||||
import org.xtimms.psu.schedule.ui.motion.LocalMorph
|
||||
import org.xtimms.psu.schedule.ui.motion.MorphKind
|
||||
import org.xtimms.psu.schedule.ui.motion.MotionDuration
|
||||
import org.xtimms.psu.schedule.ui.motion.materialSharedAxisX
|
||||
import org.xtimms.psu.schedule.ui.motion.morphOrigin
|
||||
import org.xtimms.psu.schedule.ui.motion.rememberSlideDistance
|
||||
import org.xtimms.psu.schedule.ui.notes.NoteEditor
|
||||
import org.xtimms.psu.schedule.ui.notes.NoteTarget
|
||||
import org.xtimms.psu.schedule.ui.notes.general
|
||||
import org.xtimms.psu.schedule.ui.motion.morphContent
|
||||
|
||||
private val MONTH = DateTimeFormatter.ofPattern("LLLL yyyy", Locale.forLanguageTag("ru"))
|
||||
private const val MONTHS_BEFORE = 2
|
||||
@@ -318,8 +356,8 @@ fun CalendarScreen(vm: MainViewModel, nav: AppNav) {
|
||||
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 = "Новый план") }
|
||||
.morphOrigin("fab|calendar", MaterialTheme.colorScheme.primaryContainer, 16.dp, elevation = 6.dp),
|
||||
) { Icon(Icons.Rounded.Add, contentDescription = "Новый план", modifier = Modifier.morphContent("fab|calendar")) }
|
||||
}
|
||||
|
||||
target?.let { tg ->
|
||||
@@ -1,6 +1,6 @@
|
||||
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||
|
||||
package org.xtimms.psu.schedule.ui
|
||||
package org.xtimms.psu.schedule.ui.plans
|
||||
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.animation.fadeOut
|
||||
@@ -96,6 +96,38 @@ import org.xtimms.psu.schedule.data.weekdayName
|
||||
import org.xtimms.psu.schedule.ui.theme.card
|
||||
import org.xtimms.psu.schedule.ui.theme.page
|
||||
import java.time.LocalDate
|
||||
import org.xtimms.psu.schedule.ui.AppNav
|
||||
import org.xtimms.psu.schedule.ui.MainViewModel
|
||||
import org.xtimms.psu.schedule.ui.MenuButton
|
||||
import org.xtimms.psu.schedule.ui.components.CenteredEmpty
|
||||
import org.xtimms.psu.schedule.ui.components.DetailPane
|
||||
import org.xtimms.psu.schedule.ui.components.DoneMark
|
||||
import org.xtimms.psu.schedule.ui.components.Empty
|
||||
import org.xtimms.psu.schedule.ui.components.Heading
|
||||
import org.xtimms.psu.schedule.ui.components.LocalUndo
|
||||
import org.xtimms.psu.schedule.ui.components.PaneRow
|
||||
import org.xtimms.psu.schedule.ui.components.navBarPadding
|
||||
import org.xtimms.psu.schedule.ui.components.twoPane
|
||||
import org.xtimms.psu.schedule.ui.components.weekTint
|
||||
import org.xtimms.psu.schedule.ui.home.CompactLesson
|
||||
import org.xtimms.psu.schedule.ui.lesson.SessionDot
|
||||
import org.xtimms.psu.schedule.ui.lesson.Slot
|
||||
import org.xtimms.psu.schedule.ui.lesson.rememberLessonOpener
|
||||
import org.xtimms.psu.schedule.ui.lesson.session
|
||||
import org.xtimms.psu.schedule.ui.lesson.sessionColors
|
||||
import org.xtimms.psu.schedule.ui.lesson.slotsOn
|
||||
import org.xtimms.psu.schedule.ui.lesson.subjectColor
|
||||
import org.xtimms.psu.schedule.ui.motion.LocalMorph
|
||||
import org.xtimms.psu.schedule.ui.motion.MorphKind
|
||||
import org.xtimms.psu.schedule.ui.motion.materialFadeThrough
|
||||
import org.xtimms.psu.schedule.ui.motion.morphOrigin
|
||||
import org.xtimms.psu.schedule.ui.notes.NoteEditor
|
||||
import org.xtimms.psu.schedule.ui.notes.NoteRow
|
||||
import org.xtimms.psu.schedule.ui.notes.NoteTarget
|
||||
import org.xtimms.psu.schedule.ui.search.SearchWeekStrip
|
||||
import org.xtimms.psu.schedule.ui.notes.overdue
|
||||
import org.xtimms.psu.schedule.ui.components.UndoHost
|
||||
import org.xtimms.psu.schedule.ui.motion.morphContent
|
||||
|
||||
/** "Сегодня • Суббота • 17 октября", "Завтра • …" or "Понедельник • 19 октября". */
|
||||
private fun dayTitle(d: LocalDate): String = when (d) {
|
||||
@@ -222,7 +254,7 @@ fun PlansScreen(vm: MainViewModel, nav: AppNav) {
|
||||
rows.getOrNull(i)?.date?.let { if (it != date) date = it }
|
||||
}
|
||||
}
|
||||
PlanList(list, rows, vm::toggleNote, onEdit = { editing = it }, color = color)
|
||||
PlanList(list, rows, vm::toggleNote, onEdit = { editing = it }, onPostpone = vm::postponeNote, color = color)
|
||||
}
|
||||
|
||||
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||
@@ -285,8 +317,8 @@ fun PlansScreen(vm: MainViewModel, nav: AppNav) {
|
||||
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 = "Новое задание") }
|
||||
.morphOrigin("fab|plans", MaterialTheme.colorScheme.primaryContainer, 16.dp, elevation = 6.dp),
|
||||
) { Icon(Icons.Rounded.Add, contentDescription = "Новое задание", modifier = Modifier.morphContent("fab|plans")) }
|
||||
}
|
||||
|
||||
if (tuning) ModalBottomSheet(onDismissRequest = { tuning = false }) { PlansSettings(prefs, vm::setPlans) }
|
||||
@@ -332,6 +364,7 @@ private fun PlanList(
|
||||
rows: List<Row>,
|
||||
onToggle: (String) -> Unit,
|
||||
onEdit: (Note) -> Unit,
|
||||
onPostpone: (Note, UndoHost?) -> Unit,
|
||||
color: Color = MaterialTheme.colorScheme.surface,
|
||||
) {
|
||||
if (rows.isEmpty()) {
|
||||
@@ -356,7 +389,14 @@ private fun PlanList(
|
||||
}
|
||||
is Row.Item -> item(key = "n${row.note.id}") {
|
||||
Box(Modifier.animateItem()) {
|
||||
NoteRow(row.note, onToggle = { onToggle(row.note.id) }, onClick = { onEdit(row.note) }, showSubject = true, showDue = row.date == null)
|
||||
NoteRow(
|
||||
row.note,
|
||||
onToggle = { onToggle(row.note.id) },
|
||||
onClick = { onEdit(row.note) },
|
||||
showSubject = true,
|
||||
showDue = row.date == null,
|
||||
onPostpone = { onPostpone(row.note, it) },
|
||||
)
|
||||
}
|
||||
}
|
||||
is Row.Divider -> item(key = "d$day") {
|
||||
@@ -471,7 +511,8 @@ private fun Board(
|
||||
color = MaterialTheme.colorScheme.card,
|
||||
modifier = Modifier.fillMaxWidth().border(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), MaterialTheme.shapes.large),
|
||||
) {
|
||||
NoteRow(n, onToggle = { onToggle(n.id) }, onClick = { onEdit(n) }, showSubject = true, showDue = d == null)
|
||||
// The board's columns page sideways: a sideways swipe on a task would fight them.
|
||||
NoteRow(n, onToggle = { onToggle(n.id) }, onClick = { onEdit(n) }, showSubject = true, showDue = d == null, swipe = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,10 @@
|
||||
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||
|
||||
package org.xtimms.psu.schedule.ui
|
||||
package org.xtimms.psu.schedule.ui.schedule
|
||||
|
||||
import androidx.compose.animation.core.tween
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
@@ -16,29 +14,16 @@ import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.material.icons.rounded.Close
|
||||
import androidx.compose.material.icons.rounded.Check
|
||||
import androidx.compose.foundation.interaction.collectIsDraggedAsState
|
||||
import androidx.compose.material.icons.rounded.VisibilityOff
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import org.xtimms.psu.schedule.data.hideKey
|
||||
import org.xtimms.psu.schedule.data.hiddenIn
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import kotlin.math.abs
|
||||
import androidx.compose.material.icons.rounded.Weekend
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.MaterialShapes
|
||||
import androidx.compose.material3.toShape
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.material.icons.rounded.Today
|
||||
@@ -50,12 +35,8 @@ import org.xtimms.psu.schedule.ui.theme.page
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -71,27 +52,17 @@ import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.CloudOff
|
||||
import androidx.compose.material.icons.rounded.ErrorOutline
|
||||
import androidx.compose.material.icons.rounded.EventBusy
|
||||
import androidx.compose.material.icons.rounded.EventRepeat
|
||||
import androidx.compose.material.icons.rounded.Groups
|
||||
import androidx.compose.material.icons.rounded.LocationCity
|
||||
import androidx.compose.material.icons.rounded.LocationOn
|
||||
import androidx.compose.material.icons.rounded.Person
|
||||
import androidx.compose.material.icons.rounded.Refresh
|
||||
import androidx.compose.material.icons.rounded.CalendarViewWeek
|
||||
import androidx.compose.material.icons.rounded.ViewDay
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.material.icons.rounded.Schedule
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -106,7 +77,6 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.launch
|
||||
import org.xtimms.psu.schedule.data.Bell
|
||||
import org.xtimms.psu.schedule.data.DayPlace
|
||||
import org.xtimms.psu.schedule.data.Lesson
|
||||
import org.xtimms.psu.schedule.data.Timetable
|
||||
@@ -116,9 +86,40 @@ import org.xtimms.psu.schedule.data.minutesLabel
|
||||
import org.xtimms.psu.schedule.data.monday
|
||||
import org.xtimms.psu.schedule.data.parseDate
|
||||
import org.xtimms.psu.schedule.data.today
|
||||
import org.xtimms.psu.schedule.data.withPlace
|
||||
import java.time.Duration
|
||||
import java.time.LocalDate
|
||||
import org.xtimms.psu.schedule.ui.AppNav
|
||||
import org.xtimms.psu.schedule.ui.MainViewModel
|
||||
import org.xtimms.psu.schedule.ui.MenuButton
|
||||
import org.xtimms.psu.schedule.ui.components.Banner
|
||||
import org.xtimms.psu.schedule.ui.components.CenteredEmpty
|
||||
import org.xtimms.psu.schedule.ui.components.ContentColumn
|
||||
import org.xtimms.psu.schedule.ui.components.DayStrip
|
||||
import org.xtimms.psu.schedule.ui.components.Empty
|
||||
import org.xtimms.psu.schedule.ui.components.FoldSplit
|
||||
import org.xtimms.psu.schedule.ui.components.Heading
|
||||
import org.xtimms.psu.schedule.ui.components.LocalFoldTablet
|
||||
import org.xtimms.psu.schedule.ui.components.LocalHinge
|
||||
import org.xtimms.psu.schedule.ui.components.MAX_CONTENT
|
||||
import org.xtimms.psu.schedule.ui.components.ScheduleSkeleton
|
||||
import org.xtimms.psu.schedule.ui.components.TWO_PANE
|
||||
import org.xtimms.psu.schedule.ui.components.WeekMark
|
||||
import org.xtimms.psu.schedule.ui.components.appBarColors
|
||||
import org.xtimms.psu.schedule.ui.components.navBarPadding
|
||||
import org.xtimms.psu.schedule.ui.components.trackFold
|
||||
import org.xtimms.psu.schedule.ui.components.weekTint
|
||||
import org.xtimms.psu.schedule.ui.components.weekTintOf
|
||||
import org.xtimms.psu.schedule.ui.lesson.DayPlaces
|
||||
import org.xtimms.psu.schedule.ui.lesson.LessonCard
|
||||
import org.xtimms.psu.schedule.ui.lesson.BetweenRow
|
||||
import org.xtimms.psu.schedule.ui.lesson.between
|
||||
import org.xtimms.psu.schedule.ui.lesson.Slot
|
||||
import org.xtimms.psu.schedule.ui.lesson.placesOn
|
||||
import org.xtimms.psu.schedule.ui.lesson.rememberClock
|
||||
import org.xtimms.psu.schedule.ui.lesson.rememberLessonOpener
|
||||
import org.xtimms.psu.schedule.ui.lesson.slotsOn
|
||||
import org.xtimms.psu.schedule.ui.motion.AnimatedItem
|
||||
import org.xtimms.psu.schedule.ui.motion.ExpressiveRefresh
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
@@ -470,7 +471,8 @@ private fun DayPage(
|
||||
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 }
|
||||
val before = (if (places.isNotEmpty()) 1 else 0) + slots.take(now).sumOf { it.lessons.size } +
|
||||
(1..now).count { i -> slots.getOrNull(i - 1)?.let { between(it, slots[i]) } != null }
|
||||
list.scrollToItem(before)
|
||||
}
|
||||
ContentColumn {
|
||||
@@ -487,7 +489,11 @@ private fun DayPage(
|
||||
}
|
||||
// "Занятия в Полоцке": the day's town before its lessons.
|
||||
if (places.isNotEmpty()) item(key = "places") { AnimatedItem { DayPlaces(places) } }
|
||||
slots.forEach { slot ->
|
||||
slots.forEachIndexed { index, slot ->
|
||||
// A window, another building or another town since the lesson before.
|
||||
slots.getOrNull(index - 1)?.let { prev -> between(prev, slot) }?.let { b ->
|
||||
item(key = "between|${slot.start}") { AnimatedItem { BetweenRow(b) } }
|
||||
}
|
||||
val running = isToday && !clock.isBefore(slot.start) && clock.isBefore(slot.end)
|
||||
val status = when {
|
||||
running -> "Идёт · ещё ${minutesLabel(Duration.between(clock, slot.end).toMinutes())}"
|
||||
@@ -518,260 +524,6 @@ private fun DayPage(
|
||||
}
|
||||
}
|
||||
|
||||
/** A lesson as a soft card in its subject's colour, like School Planner's day list. */
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun LessonCard(
|
||||
lesson: Lesson,
|
||||
time: String?,
|
||||
pair: Int? = null,
|
||||
date: LocalDate? = null,
|
||||
status: String? = null,
|
||||
running: Boolean = false,
|
||||
/** How far the lesson under way has gone, 0..1: a line runs round the card. */
|
||||
progress: Float? = null,
|
||||
onClick: (() -> Unit)?,
|
||||
/** Just added or changed by a republished document: the card glows twice, then [onFreshShown]. */
|
||||
fresh: Boolean = false,
|
||||
onFreshShown: () -> Unit = {},
|
||||
) {
|
||||
val color = subjectColor(lesson.subject)
|
||||
val show = LocalDisplay.current
|
||||
val session = lesson.session
|
||||
val content: @Composable () -> Unit = {
|
||||
Column(Modifier.padding(horizontal = 20.dp, vertical = 16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
// Exams, credits and consultations announce themselves before the subject.
|
||||
session?.let { SessionBadge(it, Modifier.padding(bottom = 2.dp)) }
|
||||
Row(verticalAlignment = Alignment.Top) {
|
||||
Text(lesson.subject, style = MaterialTheme.typography.titleMediumEmphasized, modifier = Modifier.weight(1f))
|
||||
status?.let {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.small,
|
||||
color = if (running) color.onSoft else color.onSoft.copy(alpha = 0.12f),
|
||||
contentColor = if (running) color.soft else color.onSoft,
|
||||
) {
|
||||
RollingText(it, style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
time?.let { TimeText(it + (pair?.takeIf { show.pairs }?.let { p -> " · $p пара" } ?: ""), style = MaterialTheme.typography.bodyLarge) }
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
if (lesson.distance) Meta(Icons.Rounded.LocationOn, "Дистанционно")
|
||||
else withPlace(
|
||||
lesson.rooms.takeIf { show.rooms && it.isNotEmpty() }?.let { it.joinToString(", ") + if (lesson.roomMoved) " (перенос)" else "" },
|
||||
lesson.place,
|
||||
)?.let { Meta(Icons.Rounded.LocationOn, it) }
|
||||
if (show.teachers && lesson.teachers.isNotEmpty()) Meta(Icons.Rounded.Person, lesson.teachers.joinToString(", "))
|
||||
lesson.subgroup?.let { Meta(Icons.Rounded.Groups, it) }
|
||||
}
|
||||
val tags = if (!show.tags) emptyList() else listOfNotNull(
|
||||
lesson.type.ifEmpty { null }?.takeIf { session == null },
|
||||
if (lesson.variant != "all") (if (lesson.variant == "upper") "белые недели" else "зелёные недели") else null,
|
||||
if (lesson.groups.size > 1) "общее занятие" else null,
|
||||
// German or French: a part of the group has this one, the rest the other.
|
||||
if (lesson.option != null) "на выбор" else null,
|
||||
when (lesson.audience) {
|
||||
"foreign" -> "для иностранных студентов"
|
||||
"local" -> "кроме иностранных студентов"
|
||||
else -> null
|
||||
},
|
||||
)
|
||||
val mark = date?.let { LocalMarks.current.forLesson(lesson.subject, it, lesson.start) }
|
||||
if (tags.isNotEmpty() || lesson.uncertain || mark != null) FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
) {
|
||||
mark?.let {
|
||||
SoftTag(if (it.present) "был" else "пропуск", color, icon = if (it.present) Icons.Rounded.Check else Icons.Rounded.Close)
|
||||
}
|
||||
tags.forEach { SoftTag(it, color) }
|
||||
if (lesson.uncertain) Tag("Сверьте недели", warning = true)
|
||||
}
|
||||
if (show.notes && lesson.condition.isNotEmpty()) Meta(Icons.Rounded.EventRepeat, "Пометка в Word: ${lesson.condition}")
|
||||
if (date != null) NotesHint(LocalNotes.current.forLesson(lesson.subject, date, lesson.start), color.onSoft)
|
||||
}
|
||||
}
|
||||
// A tapped card grows into its details page (Morph.kt).
|
||||
val glow = remember { androidx.compose.animation.core.Animatable(0f) }
|
||||
val reduced = rememberReducedMotion()
|
||||
LaunchedEffect(fresh) {
|
||||
if (!fresh) return@LaunchedEffect
|
||||
if (!reduced) repeat(2) {
|
||||
glow.animateTo(1f, tween(350, easing = EmphasizedDecelerate))
|
||||
glow.animateTo(0f, tween(450))
|
||||
}
|
||||
onFreshShown()
|
||||
}
|
||||
// The fill itself turns tertiary and back, so the text stays as crisp as on any card.
|
||||
val highlight = MaterialTheme.colorScheme.tertiaryContainer
|
||||
val fill = if (glow.value > 0f) androidx.compose.ui.graphics.lerp(color.soft, highlight, glow.value) else color.soft
|
||||
val origin = if (onClick != null) Modifier.morphOrigin("card|$date|${lesson.key}|${lesson.start}", color.soft, 16.dp) else Modifier
|
||||
// The line round a running card grows a little every half minute, never jumps.
|
||||
val shown by androidx.compose.animation.core.animateFloatAsState(progress ?: 0f, tween(1000), label = "progress")
|
||||
val live = if (running && progress != null) Modifier.progressBorder(shown, color.onSoft, 16.dp) else Modifier
|
||||
val border = when {
|
||||
running && progress != null -> null
|
||||
running -> BorderStroke(2.dp, color.onSoft)
|
||||
session != null -> BorderStroke(2.dp, session.outline())
|
||||
else -> null
|
||||
}
|
||||
if (onClick != null) Surface(
|
||||
onClick = onClick,
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = fill,
|
||||
contentColor = color.onSoft,
|
||||
border = border,
|
||||
modifier = Modifier.fillMaxWidth().then(origin).then(live),
|
||||
content = content,
|
||||
) else Surface(
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = color.soft,
|
||||
contentColor = color.onSoft,
|
||||
border = border,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SoftTag(text: String, color: SubjectColor, icon: ImageVector? = null) {
|
||||
Surface(shape = MaterialTheme.shapes.small, color = color.onSoft.copy(alpha = 0.12f), contentColor = color.onSoft) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(start = if (icon != null) 6.dp else 8.dp, end = 8.dp, top = 3.dp, bottom = 3.dp),
|
||||
) {
|
||||
icon?.let {
|
||||
Icon(it, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
Spacer(Modifier.width(4.dp))
|
||||
}
|
||||
Text(text.replaceFirstChar { it.titlecase() }, style = MaterialTheme.typography.labelLarge)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Meta(icon: androidx.compose.ui.graphics.vector.ImageVector, text: String) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(icon, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(text, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TypeTag(type: String) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val (bg, fg) = when (type) {
|
||||
"Лекция" -> scheme.primaryContainer to scheme.onPrimaryContainer
|
||||
"Практика" -> scheme.tertiaryContainer to scheme.onTertiaryContainer
|
||||
"Экзамен", "Зачёт", "Защита курсовой работы" -> sessionColors().let { it.strong to it.onStrong }
|
||||
"Консультация" -> sessionColors().let { it.soft to it.onSoft }
|
||||
else -> scheme.secondaryContainer to scheme.onSecondaryContainer
|
||||
}
|
||||
Surface(shape = MaterialTheme.shapes.small, color = bg, contentColor = fg) {
|
||||
Text(type.ifEmpty { "Занятие" }, style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Tag(text: String, warning: Boolean = false) {
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.small,
|
||||
color = if (warning) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
contentColor = if (warning) MaterialTheme.colorScheme.onErrorContainer else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
) {
|
||||
Text(text, style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun LessonDetails(
|
||||
lesson: Lesson,
|
||||
bells: List<Bell>,
|
||||
date: LocalDate? = null,
|
||||
vm: MainViewModel? = null,
|
||||
onClose: () -> Unit = {},
|
||||
) {
|
||||
val bell = bells.firstOrNull { it.start == lesson.start }
|
||||
// Hiding closes the sheet, so its "Вернуть" goes to the screen; tasks deleted here, to the sheet.
|
||||
val screenUndo = LocalUndo.current
|
||||
UndoArea(bottom = false) {
|
||||
Column(
|
||||
Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(start = 24.dp, end = 24.dp, bottom = 32.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
TypeTag(lesson.type)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
SubjectDot(lesson.subject, 16.dp)
|
||||
Spacer(Modifier.width(14.dp))
|
||||
Heading(lesson.subject, style = MaterialTheme.typography.headlineSmallEmphasized)
|
||||
}
|
||||
Detail(
|
||||
Icons.Rounded.Schedule,
|
||||
bell?.let { "${it.pair} пара" } ?: "Время",
|
||||
lesson.start + (lesson.end?.let { " — $it" } ?: ""),
|
||||
bell?.halves?.takeIf { it.size > 1 }?.joinToString(", ") { it.joinToString("–") },
|
||||
)
|
||||
Detail(Icons.Rounded.Person, "Преподаватель", lesson.teachers.joinToString(", ").ifEmpty { "Не указан" })
|
||||
Detail(
|
||||
Icons.Rounded.LocationOn,
|
||||
"Аудитория",
|
||||
if (lesson.distance) "Дистанционно" else lesson.rooms.joinToString(", ").ifEmpty { "Не указана" },
|
||||
if (lesson.roomMoved) "В этот день — перенос аудитории" else null,
|
||||
)
|
||||
lesson.place?.let { Detail(Icons.Rounded.LocationCity, "Город", it, "Так указано в Word") }
|
||||
Detail(Icons.Rounded.Groups, "Группы", lesson.groups.joinToString(", "), lesson.subgroup)
|
||||
if (lesson.uncertain) Banner(
|
||||
"Пометку «${lesson.condition}» не удалось сопоставить с таблицей недель. Сверьте с Word.",
|
||||
offline = false,
|
||||
onClose = null,
|
||||
)
|
||||
if (date != null && vm != null) {
|
||||
LessonAttendance(vm, lesson, date)
|
||||
LessonNotes(vm, lesson, date)
|
||||
}
|
||||
Heading("Исходная запись", style = MaterialTheme.typography.titleSmallEmphasized)
|
||||
Surface(shape = MaterialTheme.shapes.large, color = MaterialTheme.colorScheme.surfaceContainerHigh) {
|
||||
Text(lesson.raw, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(16.dp).fillMaxWidth())
|
||||
}
|
||||
if (vm != null) {
|
||||
OutlinedButton(onClick = {
|
||||
vm.setHidden(lesson.hideKey, true)
|
||||
onClose()
|
||||
screenUndo?.offer("Скрыто: «${lesson.subject}»", onUndo = { vm.setHidden(lesson.hideKey, false) })
|
||||
}) {
|
||||
Icon(Icons.Rounded.VisibilityOff, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Скрыть такие занятия")
|
||||
}
|
||||
Text(
|
||||
"«${lesson.subject}»${lesson.type.takeIf { it.isNotEmpty() }?.let { " · ${it.lowercase()}" }.orEmpty()} пропадёт из расписания, " +
|
||||
"обзора, виджета и напоминаний во все недели. Вернуть можно в настройках.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Detail(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, value: String, extra: String? = null) {
|
||||
Row {
|
||||
Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 2.dp))
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Column {
|
||||
Text(label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(value, style = MaterialTheme.typography.bodyLargeEmphasized)
|
||||
extra?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** When the week table has no semester for this schedule: the Word rows by weekday. */
|
||||
@Composable
|
||||
private fun WordRows(t: Timetable, subgroup: String?, hidden: Set<String>, onOpen: (Lesson) -> Unit) {
|
||||
@@ -795,140 +547,3 @@ private fun WordRows(t: Timetable, subgroup: String?, hidden: Set<String>, onOpe
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Banner(
|
||||
text: String,
|
||||
offline: Boolean,
|
||||
onClose: (() -> Unit)?,
|
||||
onRetry: (() -> Unit)? = null,
|
||||
/** A way out the message asks for, such as "Выбрать группу". */
|
||||
action: Pair<String, () -> Unit>? = null,
|
||||
) {
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = if (offline) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.errorContainer,
|
||||
contentColor = if (offline) MaterialTheme.colorScheme.onSecondaryContainer else MaterialTheme.colorScheme.onErrorContainer,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
) {
|
||||
val buttons: @Composable () -> Unit = {
|
||||
if (onRetry != null) TextButton(onClick = onRetry) { Text("Повторить") }
|
||||
action?.let { (label, onClick) -> TextButton(onClick = onClick) { Text(label) } }
|
||||
if (onClose != null) TextButton(onClick = onClose) { Text("OK") }
|
||||
}
|
||||
// With an action the message needs the whole width; the buttons go under it.
|
||||
if (action != null) Column(Modifier.padding(start = 16.dp, end = 4.dp, top = 12.dp, bottom = 4.dp)) {
|
||||
Row {
|
||||
Icon(Icons.Rounded.ErrorOutline.takeIf { !offline } ?: Icons.Rounded.CloudOff, contentDescription = null)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text(text, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f).padding(end = 12.dp))
|
||||
}
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { buttons() }
|
||||
} else Row(Modifier.padding(start = 16.dp, end = 4.dp, top = 8.dp, bottom = 8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(if (offline) Icons.Rounded.CloudOff else Icons.Rounded.ErrorOutline, contentDescription = null)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text(text, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f).padding(vertical = 8.dp))
|
||||
buttons()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Centres an empty state in the space it is given — a phone screen or a tablet's pane —
|
||||
* and scrolls when that space is too low, so the illustration is never squeezed.
|
||||
*/
|
||||
@Composable
|
||||
fun CenteredEmpty(
|
||||
modifier: Modifier = Modifier,
|
||||
/** Room kept clear inside the scroll (under a FAB): content scrolls past it, never gets cut. */
|
||||
bottomInset: Dp = 0.dp,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
BoxWithConstraints(modifier.fillMaxSize()) {
|
||||
val height = maxHeight
|
||||
Column(
|
||||
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).heightIn(min = height).padding(bottom = bottomInset),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) { content() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty state in the RideBus style: an icon on a seven-sided cookie shape, a title,
|
||||
* a calm explanation and an optional tonal action.
|
||||
*/
|
||||
@Composable
|
||||
fun Empty(
|
||||
title: String,
|
||||
text: String,
|
||||
icon: ImageVector = Icons.Rounded.EventBusy,
|
||||
action: Pair<String, () -> Unit>? = null,
|
||||
) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth().padding(vertical = 48.dp, horizontal = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.size(112.dp)
|
||||
.background(MaterialTheme.colorScheme.primaryContainer, MaterialShapes.Cookie7Sided.toShape()),
|
||||
) {
|
||||
Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.onPrimaryContainer, modifier = Modifier.size(48.dp))
|
||||
}
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Text(title, style = MaterialTheme.typography.titleLargeEmphasized, textAlign = TextAlign.Center)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
text,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
action?.let { (label, onClick) ->
|
||||
Spacer(Modifier.height(20.dp))
|
||||
FilledTonalButton(onClick = onClick) { Text(label) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val FACULTIES = mapOf(
|
||||
"Финансово-экономический факультет" to "ФЭФ",
|
||||
"Факультет информационных технологий" to "ФИТ",
|
||||
"Факультет компьютерных наук и электроники" to "ФКНЭ",
|
||||
"Гуманитарный факультет" to "ГФ",
|
||||
"Инженерно-строительный факультет" to "ИСФ",
|
||||
"Механико-технологический факультет" to "МТФ",
|
||||
"Юридический факультет" to "ЮФ",
|
||||
)
|
||||
|
||||
fun facultyShort(name: String): String? = name.takeIf { it.isNotEmpty() }?.let { FACULTIES[it] ?: it }
|
||||
|
||||
/**
|
||||
* Opens a lesson's details as a page growing out of the card that was tapped (Morph.kt); from
|
||||
* anywhere else (the week grid of a narrow column, a notification) it grows from the middle.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberLessonOpener(vm: MainViewModel): (Lesson, LocalDate?) -> Unit {
|
||||
val morph = LocalMorph.current
|
||||
return remember(morph, vm) {
|
||||
{ lesson, date -> morph?.open(MorphKind.Page) { close -> LessonPage(lesson, date, vm, close) } }
|
||||
}
|
||||
}
|
||||
|
||||
/** The details as a page: a close button over them; full screen on a phone, a panel on a tablet. */
|
||||
@Composable
|
||||
private fun LessonPage(lesson: Lesson, date: LocalDate?, vm: MainViewModel, close: () -> Unit) {
|
||||
val timetable by vm.timetable.collectAsStateWithLifecycle()
|
||||
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||
// The phone's page covers the bars; a tablet's panel sits inside them.
|
||||
val insets = if (maxWidth >= 600.dp) Modifier else Modifier.windowInsetsPadding(WindowInsets.systemBars)
|
||||
Column(Modifier.fillMaxSize().then(insets)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(start = 8.dp, top = 4.dp)) {
|
||||
IconButton(onClick = close) { Icon(Icons.Rounded.Close, contentDescription = "Закрыть") }
|
||||
}
|
||||
LessonDetails(lesson, timetable?.bells.orEmpty(), date, vm, onClose = close)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package org.xtimms.psu.schedule.ui
|
||||
package org.xtimms.psu.schedule.ui.schedule
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -49,6 +49,19 @@ import org.xtimms.psu.schedule.data.weekdayName
|
||||
import java.time.Duration
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalTime
|
||||
import org.xtimms.psu.schedule.ui.components.Heading
|
||||
import org.xtimms.psu.schedule.ui.components.TimeText
|
||||
import org.xtimms.psu.schedule.ui.components.WeekMark
|
||||
import org.xtimms.psu.schedule.ui.home.CompactLesson
|
||||
import org.xtimms.psu.schedule.ui.lesson.LocalDisplay
|
||||
import org.xtimms.psu.schedule.ui.lesson.SessionBadge
|
||||
import org.xtimms.psu.schedule.ui.lesson.Slot
|
||||
import org.xtimms.psu.schedule.ui.lesson.SubjectDot
|
||||
import org.xtimms.psu.schedule.ui.lesson.session
|
||||
import org.xtimms.psu.schedule.ui.lesson.subjectColor
|
||||
import org.xtimms.psu.schedule.ui.motion.RollingText
|
||||
import org.xtimms.psu.schedule.ui.motion.morphOrigin
|
||||
import org.xtimms.psu.schedule.ui.motion.morphContent
|
||||
|
||||
// The schedule of a foldable half opened on a table, as a desk stand: above the fold, facing the
|
||||
// student, one lesson large enough to read from a distance; below it, under the thumb, the bar,
|
||||
@@ -133,7 +146,7 @@ private fun StandLesson(slot: Slot, clock: LocalTime, isToday: Boolean, onOpen:
|
||||
contentColor = color.onSoft,
|
||||
modifier = modifier.morphOrigin("stand|${slot.date}|${lesson.key}|${lesson.start}", color.soft, 28.dp),
|
||||
) {
|
||||
Column(Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Column(Modifier.morphContent("stand|${slot.date}|${lesson.key}|${lesson.start}").padding(24.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
TimeText(
|
||||
"${slot.start} → ${slot.end}" + (lesson.pair?.let { " · $it пара" } ?: ""),
|
||||
@@ -1,6 +1,6 @@
|
||||
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
|
||||
package org.xtimms.psu.schedule.ui
|
||||
package org.xtimms.psu.schedule.ui.schedule
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.animation.core.tween
|
||||
@@ -50,6 +50,27 @@ import org.xtimms.psu.schedule.data.weekdayName
|
||||
import org.xtimms.psu.schedule.data.withPlace
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalTime
|
||||
import org.xtimms.psu.schedule.ui.MainViewModel
|
||||
import org.xtimms.psu.schedule.ui.components.navBarPadding
|
||||
import org.xtimms.psu.schedule.ui.lesson.DayPlaceMark
|
||||
import org.xtimms.psu.schedule.ui.lesson.LocalDisplay
|
||||
import org.xtimms.psu.schedule.ui.lesson.SessionDot
|
||||
import org.xtimms.psu.schedule.ui.lesson.Slot
|
||||
import org.xtimms.psu.schedule.ui.lesson.abbreviate
|
||||
import org.xtimms.psu.schedule.ui.lesson.outline
|
||||
import org.xtimms.psu.schedule.ui.lesson.placesOn
|
||||
import org.xtimms.psu.schedule.ui.lesson.rememberClock
|
||||
import org.xtimms.psu.schedule.ui.lesson.session
|
||||
import org.xtimms.psu.schedule.ui.lesson.slotsOn
|
||||
import org.xtimms.psu.schedule.ui.lesson.subjectColor
|
||||
import org.xtimms.psu.schedule.ui.motion.morphContent
|
||||
import org.xtimms.psu.schedule.ui.motion.morphOrigin
|
||||
import org.xtimms.psu.schedule.ui.notes.LocalNotes
|
||||
import org.xtimms.psu.schedule.ui.lesson.forLesson
|
||||
import org.xtimms.psu.schedule.ui.notes.forLesson
|
||||
import org.xtimms.psu.schedule.ui.notes.open
|
||||
import androidx.compose.ui.draw.clip
|
||||
import org.xtimms.psu.schedule.ui.lesson.lessonPress
|
||||
|
||||
private val HOUR = 60.dp
|
||||
private val GUTTER = 36.dp
|
||||
@@ -182,7 +203,8 @@ fun WeekGrid(
|
||||
private fun LessonBlock(lesson: Lesson, slot: Slot, wide: Boolean, notes: Int, onClick: () -> Unit, modifier: Modifier) {
|
||||
val color = subjectColor(lesson.subject)
|
||||
val session = lesson.session
|
||||
val origin = Modifier.morphOrigin("grid|${slot.date}|${lesson.key}|${lesson.start}", color.container, 8.dp)
|
||||
val morphKey = "grid|${slot.date}|${lesson.key}|${lesson.start}"
|
||||
val origin = Modifier.morphOrigin(morphKey, color.container, 8.dp)
|
||||
val rooms = lesson.rooms.joinToString(", ").takeIf { LocalDisplay.current.rooms }.orEmpty()
|
||||
// A narrow column keeps the room whole: the day's town is under its date.
|
||||
val room = when {
|
||||
@@ -191,17 +213,16 @@ private fun LessonBlock(lesson: Lesson, slot: Slot, wide: Boolean, notes: Int, o
|
||||
else -> rooms.ifEmpty { lesson.place.orEmpty() }
|
||||
}
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
shape = MaterialTheme.shapes.small,
|
||||
color = color.container,
|
||||
contentColor = color.content,
|
||||
border = session?.let { androidx.compose.foundation.BorderStroke(2.dp, it.outline()) },
|
||||
modifier = modifier.then(origin).semantics {
|
||||
modifier = modifier.then(origin).clip(MaterialTheme.shapes.small).lessonPress(lesson, slot.date, onClick).semantics {
|
||||
contentDescription = "${lesson.subject}, ${slot.start}–${slot.end}" +
|
||||
(if (room.isNotEmpty()) ", ауд. $room" else "") + (lesson.type.takeIf { it.isNotEmpty() }?.let { ", $it" } ?: "") +
|
||||
(if (notes > 0) ", заданий и заметок: $notes" else "")
|
||||
},
|
||||
) {
|
||||
) { Box(Modifier.fillMaxSize().morphContent(morphKey)) {
|
||||
// A small dot in the corner marks lessons with open tasks or notes.
|
||||
if (notes > 0) Box(Modifier.fillMaxSize().padding(5.dp), contentAlignment = Alignment.TopEnd) {
|
||||
Box(Modifier.size(8.dp).background(color.content, CircleShape))
|
||||
@@ -217,5 +238,5 @@ private fun LessonBlock(lesson: Lesson, slot: Slot, wide: Boolean, notes: Int, o
|
||||
)
|
||||
if (room.isNotEmpty()) Text(room, style = MaterialTheme.typography.labelSmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
} }
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||
|
||||
package org.xtimms.psu.schedule.ui
|
||||
package org.xtimms.psu.schedule.ui.search
|
||||
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
@@ -75,18 +75,77 @@ import org.xtimms.psu.schedule.data.Lesson
|
||||
import org.xtimms.psu.schedule.data.monday
|
||||
import org.xtimms.psu.schedule.data.today
|
||||
import java.time.LocalDate
|
||||
import org.xtimms.psu.schedule.ui.AppNav
|
||||
import org.xtimms.psu.schedule.ui.MainViewModel
|
||||
import org.xtimms.psu.schedule.ui.MenuButton
|
||||
import org.xtimms.psu.schedule.ui.components.Banner
|
||||
import org.xtimms.psu.schedule.ui.components.CardRow
|
||||
import org.xtimms.psu.schedule.ui.components.CenteredEmpty
|
||||
import org.xtimms.psu.schedule.ui.components.ContentColumn
|
||||
import org.xtimms.psu.schedule.ui.components.DayStrip
|
||||
import org.xtimms.psu.schedule.ui.components.DetailPane
|
||||
import org.xtimms.psu.schedule.ui.components.Empty
|
||||
import org.xtimms.psu.schedule.ui.components.Heading
|
||||
import org.xtimms.psu.schedule.ui.components.LessonsSkeleton
|
||||
import org.xtimms.psu.schedule.ui.components.ListCard
|
||||
import org.xtimms.psu.schedule.ui.components.MAX_CONTENT
|
||||
import org.xtimms.psu.schedule.ui.components.PaneRow
|
||||
import org.xtimms.psu.schedule.ui.components.RowDivider
|
||||
import org.xtimms.psu.schedule.ui.components.SearchField
|
||||
import org.xtimms.psu.schedule.ui.components.WeekMark
|
||||
import org.xtimms.psu.schedule.ui.components.avatarLabel
|
||||
import org.xtimms.psu.schedule.ui.components.isTablet
|
||||
import org.xtimms.psu.schedule.ui.components.navBarPadding
|
||||
import org.xtimms.psu.schedule.ui.components.twoPane
|
||||
import org.xtimms.psu.schedule.ui.components.weekTint
|
||||
import org.xtimms.psu.schedule.ui.components.weekTintOf
|
||||
import org.xtimms.psu.schedule.ui.groups.facultyShort
|
||||
import org.xtimms.psu.schedule.ui.lesson.LessonCard
|
||||
import org.xtimms.psu.schedule.ui.lesson.subjectColor
|
||||
import org.xtimms.psu.schedule.ui.motion.AnimatedItem
|
||||
import org.xtimms.psu.schedule.ui.schedule.weekTitle
|
||||
import androidx.compose.material.icons.rounded.Star
|
||||
import androidx.compose.material.icons.rounded.StarBorder
|
||||
import androidx.compose.material.icons.rounded.History
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import org.xtimms.psu.schedule.data.minutesLabel
|
||||
import org.xtimms.psu.schedule.data.parseTime
|
||||
import org.xtimms.psu.schedule.ui.lesson.rememberClock
|
||||
import java.time.Duration
|
||||
|
||||
private sealed interface Target {
|
||||
data class Teacher(val name: String) : Target
|
||||
data class Room(val room: String) : Target
|
||||
}
|
||||
|
||||
/** How a teacher or a room is kept among the favorites and the recent ones: "teacher:Ковшик О.С.". */
|
||||
private val Target.key: String
|
||||
get() = when (this) {
|
||||
is Target.Teacher -> "teacher:$name"
|
||||
is Target.Room -> "room:$room"
|
||||
}
|
||||
|
||||
private fun targetOf(key: String): Target? = when {
|
||||
key.startsWith("teacher:") -> Target.Teacher(key.removePrefix("teacher:"))
|
||||
key.startsWith("room:") -> Target.Room(key.removePrefix("room:"))
|
||||
else -> null
|
||||
}
|
||||
|
||||
private val Target.title: String
|
||||
get() = when (this) {
|
||||
is Target.Teacher -> name
|
||||
is Target.Room -> "Аудитория $room"
|
||||
}
|
||||
|
||||
internal fun startDate(): LocalDate = if (today().dayOfWeek.value == 7) today().plusDays(1) else today()
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class, ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun SearchScreen(vm: MainViewModel, nav: AppNav) {
|
||||
val timetable by vm.timetable.collectAsStateWithLifecycle()
|
||||
val settings by vm.settings.collectAsStateWithLifecycle()
|
||||
val favorites = settings?.searchFavorites.orEmpty()
|
||||
var query by rememberSaveable { mutableStateOf("") }
|
||||
var target by remember { mutableStateOf<Target?>(null) }
|
||||
var date by remember { mutableStateOf(startDate()) }
|
||||
@@ -106,6 +165,8 @@ fun SearchScreen(vm: MainViewModel, nav: AppNav) {
|
||||
delay(250)
|
||||
attempt { vm.api.searchDirectory(query.trim()) }.onSuccess { hits = it; error = null }.onFailure { error = it.message }
|
||||
}
|
||||
// Whoever is opened goes to the top of "Недавние".
|
||||
LaunchedEffect(target) { target?.let { vm.rememberSearch(it.key) } }
|
||||
LaunchedEffect(target, date, retries) {
|
||||
val t = target ?: return@LaunchedEffect
|
||||
loading = true
|
||||
@@ -180,6 +241,7 @@ fun SearchScreen(vm: MainViewModel, nav: AppNav) {
|
||||
if (dated && date != startDate()) IconButton(onClick = { date = startDate() }) {
|
||||
Icon(Icons.Rounded.Today, contentDescription = "Сегодня")
|
||||
}
|
||||
target?.let { t -> FavoriteButton(t.key in favorites) { vm.toggleSearchFavorite(t.key) } }
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -195,13 +257,26 @@ fun SearchScreen(vm: MainViewModel, nav: AppNav) {
|
||||
if (target == null || pane) {
|
||||
item { SearchField(query, { query = it }, "Фамилия или номер аудитории") }
|
||||
val h = hits
|
||||
if (h == null && !pane) item {
|
||||
Box(Modifier.fillParentMaxHeight(0.75f).fillMaxWidth(), contentAlignment = Alignment.Center) { Empty(
|
||||
"Кто где",
|
||||
"Найдите преподавателя или аудиторию — покажем занятия на выбранный день по всем расписаниям университета.",
|
||||
icon = Icons.Rounded.PersonSearch,
|
||||
) }
|
||||
} else if (h != null) {
|
||||
if (h == null) {
|
||||
// Before a query: the starred ones and the latest, a tap from today's day.
|
||||
val starred = favorites.mapNotNull(::targetOf)
|
||||
val recent = settings?.searchRecent.orEmpty().filter { it !in favorites }.mapNotNull(::targetOf)
|
||||
val open: (Target) -> Unit = { target = it; date = startDate() }
|
||||
if (starred.isNotEmpty()) item(key = "starred") {
|
||||
SavedTargets(Icons.Rounded.Star, "Избранное", starred, favorites, open, vm::toggleSearchFavorite)
|
||||
}
|
||||
if (recent.isNotEmpty()) item(key = "recent") {
|
||||
SavedTargets(Icons.Rounded.History, "Недавние", recent, favorites, open, vm::toggleSearchFavorite)
|
||||
}
|
||||
if (starred.isEmpty() && recent.isEmpty() && !pane) item {
|
||||
Box(Modifier.fillParentMaxHeight(0.75f).fillMaxWidth(), contentAlignment = Alignment.Center) { Empty(
|
||||
"Кто где",
|
||||
"Найдите преподавателя или аудиторию — покажем занятия на выбранный день по всем расписаниям университета. " +
|
||||
"Звёздочкой их можно добавить в избранное.",
|
||||
icon = Icons.Rounded.PersonSearch,
|
||||
) }
|
||||
}
|
||||
} else {
|
||||
if (h.teachers.isNotEmpty()) item {
|
||||
ListCard(Icons.Rounded.Person, "Преподаватели") {
|
||||
h.teachers.forEachIndexed { index, t ->
|
||||
@@ -235,6 +310,7 @@ fun SearchScreen(vm: MainViewModel, nav: AppNav) {
|
||||
}
|
||||
}
|
||||
val dayList: @Composable (Modifier) -> Unit = { modifier ->
|
||||
val clock = rememberClock()
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 24.dp + navBarPadding()),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
@@ -250,6 +326,12 @@ fun SearchScreen(vm: MainViewModel, nav: AppNav) {
|
||||
}
|
||||
}
|
||||
items(d.lessons, key = { "${it.start}|${it.subject}|${it.rooms}" }) { l -> AnimatedItem {
|
||||
// Today: where the teacher is now and where next, as on the student's own day.
|
||||
val start = parseTime(l.start)
|
||||
val end = l.end?.let(::parseTime) ?: start.plusMinutes(85)
|
||||
val now = date == today()
|
||||
val running = now && !clock.isBefore(start) && clock.isBefore(end)
|
||||
val soon = now && clock.isBefore(start) && Duration.between(clock, start).toMinutes() <= 120
|
||||
LessonCard(
|
||||
Lesson(
|
||||
key = l.start + l.subject,
|
||||
@@ -269,6 +351,13 @@ fun SearchScreen(vm: MainViewModel, nav: AppNav) {
|
||||
),
|
||||
time = l.start + (l.end?.let { " → $it" } ?: ""),
|
||||
pair = bells.firstOrNull { it.start == l.start }?.pair,
|
||||
status = when {
|
||||
running -> "Идёт · ещё ${minutesLabel(Duration.between(clock, end).toMinutes())}"
|
||||
soon -> "Через ${minutesLabel(Duration.between(clock, start).toMinutes())}"
|
||||
else -> null
|
||||
},
|
||||
running = running,
|
||||
progress = if (running) Duration.between(start, clock).toSeconds().toFloat() / Duration.between(start, end).toSeconds().coerceAtLeast(1) else null,
|
||||
onClick = null,
|
||||
) }
|
||||
}
|
||||
@@ -323,6 +412,7 @@ fun SearchScreen(vm: MainViewModel, nav: AppNav) {
|
||||
if (date != startDate()) IconButton(onClick = { date = startDate() }) {
|
||||
Icon(Icons.Rounded.Today, contentDescription = "Сегодня")
|
||||
}
|
||||
FavoriteButton(t.key in favorites) { vm.toggleSearchFavorite(t.key) }
|
||||
IconButton(onClick = { target = null; day = null }) {
|
||||
Icon(Icons.Rounded.Close, contentDescription = "Закрыть")
|
||||
}
|
||||
@@ -411,6 +501,49 @@ private const val SPAN = 52
|
||||
@Composable
|
||||
private fun SectionTitle(text: String) = Heading(text)
|
||||
|
||||
/** The star of a teacher or a room: filled while it is among the favorites. */
|
||||
@Composable
|
||||
private fun FavoriteButton(starred: Boolean, onToggle: () -> Unit) {
|
||||
IconButton(onClick = onToggle) {
|
||||
Icon(
|
||||
if (starred) Icons.Rounded.Star else Icons.Rounded.StarBorder,
|
||||
contentDescription = if (starred) "Убрать из избранного" else "В избранное",
|
||||
tint = if (starred) MaterialTheme.colorScheme.primary else LocalContentColor.current,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** "Избранное" or "Недавние": a row per teacher or room, each with its star. */
|
||||
@Composable
|
||||
private fun SavedTargets(
|
||||
icon: ImageVector,
|
||||
title: String,
|
||||
targets: List<Target>,
|
||||
favorites: List<String>,
|
||||
onOpen: (Target) -> Unit,
|
||||
onStar: (String) -> Unit,
|
||||
) {
|
||||
ListCard(icon, title) {
|
||||
targets.forEachIndexed { index, t ->
|
||||
if (index > 0) RowDivider()
|
||||
CardRow(
|
||||
avatar = when (t) {
|
||||
is Target.Teacher -> avatarLabel(t.name)
|
||||
is Target.Room -> t.room.take(4)
|
||||
},
|
||||
color = subjectColor(t.title),
|
||||
title = t.title,
|
||||
subtitle = when (t) {
|
||||
is Target.Teacher -> "Преподаватель · занятия сегодня"
|
||||
is Target.Room -> "Занятия сегодня"
|
||||
},
|
||||
onClick = { onOpen(t) },
|
||||
trailing = { FavoriteButton(t.key in favorites) { onStar(t.key) } },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun RoomChips(rooms: List<String>, onClick: (String) -> Unit) {
|
||||
@@ -1,6 +1,6 @@
|
||||
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||
|
||||
package org.xtimms.psu.schedule.ui
|
||||
package org.xtimms.psu.schedule.ui.settings
|
||||
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
@@ -48,6 +48,21 @@ import org.xtimms.psu.schedule.data.ThemeMode
|
||||
import org.xtimms.psu.schedule.data.OPTION_PREFIX
|
||||
import org.xtimms.psu.schedule.data.AUDIENCE_PREFIX
|
||||
import org.xtimms.psu.schedule.notify.Reminders
|
||||
import org.xtimms.psu.schedule.ui.MainViewModel
|
||||
import org.xtimms.psu.schedule.ui.components.ConnectedChoice
|
||||
import org.xtimms.psu.schedule.ui.components.ContentColumn
|
||||
import org.xtimms.psu.schedule.ui.components.Heading
|
||||
import org.xtimms.psu.schedule.ui.components.appBarColors
|
||||
import org.xtimms.psu.schedule.ui.components.navBarPadding
|
||||
import org.xtimms.psu.schedule.ui.home.ChangesSheet
|
||||
import org.xtimms.psu.schedule.ui.home.changeDate
|
||||
import org.xtimms.psu.schedule.ui.home.changesLabel
|
||||
import org.xtimms.psu.schedule.ui.theme.LocalThemeReveal
|
||||
import org.xtimms.psu.schedule.ui.theme.TapPoint
|
||||
import org.xtimms.psu.schedule.ui.theme.rememberTapPoint
|
||||
import org.xtimms.psu.schedule.ui.theme.tapPoint
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -56,6 +71,14 @@ fun SettingsScreen(vm: MainViewModel, onBack: () -> Unit) {
|
||||
val timetable by vm.timetable.collectAsStateWithLifecycle()
|
||||
val changes by vm.changes.collectAsStateWithLifecycle()
|
||||
var changesOpen by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(false) }
|
||||
// A new theme spreads from the option or the switch that was tapped.
|
||||
val reveal = LocalThemeReveal.current
|
||||
val themeTap = rememberTapPoint()
|
||||
val colorsTap = rememberTapPoint()
|
||||
val scope = rememberCoroutineScope()
|
||||
fun reveal(tap: TapPoint, apply: () -> Unit) {
|
||||
scope.launch { reveal?.change(tap.at, apply) ?: apply() }
|
||||
}
|
||||
val s = settings ?: return
|
||||
val context = LocalContext.current
|
||||
val permission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||
@@ -100,15 +123,15 @@ fun SettingsScreen(vm: MainViewModel, onBack: () -> Unit) {
|
||||
ConnectedChoice(
|
||||
options = listOf(ThemeMode.System to "Авто", ThemeMode.Light to "Светлая", ThemeMode.Dark to "Тёмная"),
|
||||
selected = s.theme,
|
||||
onSelect = vm::setTheme,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onSelect = { mode -> if (mode != s.theme) reveal(themeTap) { vm.setTheme(mode) } },
|
||||
modifier = Modifier.fillMaxWidth().tapPoint(themeTap),
|
||||
)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) SwitchRow(
|
||||
"Цвета обоев",
|
||||
"Цвета приложения подстраиваются под обои телефона",
|
||||
s.dynamicColor,
|
||||
vm::setDynamicColor,
|
||||
)
|
||||
Modifier.tapPoint(colorsTap),
|
||||
) { on -> reveal(colorsTap) { vm.setDynamicColor(on) } }
|
||||
}
|
||||
Section("Расписание") {
|
||||
val d = s.display
|
||||
@@ -196,7 +219,7 @@ fun SettingsScreen(vm: MainViewModel, onBack: () -> Unit) {
|
||||
"Занятия в другом городе",
|
||||
"Вечером накануне — «Завтра занятия в Полоцке» с первой парой и аудиторией. Если включены напоминания, о первой паре такого дня напомнит раньше: нужно время на дорогу.",
|
||||
s.travelAlerts && allowed,
|
||||
notifying(vm::setTravelAlerts),
|
||||
change = notifying(vm::setTravelAlerts),
|
||||
)
|
||||
if (s.travelAlerts && allowed) {
|
||||
Text("Вечером в", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
@@ -222,7 +245,7 @@ fun SettingsScreen(vm: MainViewModel, onBack: () -> Unit) {
|
||||
"Переносы аудиторий",
|
||||
"Утром в день переноса — какая пара и в какой аудитории, вместо обычной.",
|
||||
s.roomAlerts && allowed,
|
||||
notifying(vm::setRoomAlerts),
|
||||
change = notifying(vm::setRoomAlerts),
|
||||
)
|
||||
if (s.roomAlerts && allowed) {
|
||||
Text("Утром в", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
@@ -238,7 +261,7 @@ fun SettingsScreen(vm: MainViewModel, onBack: () -> Unit) {
|
||||
"Текущая пара в шторке",
|
||||
"Пока идёт пара — уведомление с оставшимся временем и следующей парой. На Android 16 оно видно в строке состояния и на экране блокировки.",
|
||||
s.liveLesson && allowed,
|
||||
notifying(vm::setLiveLesson),
|
||||
change = notifying(vm::setLiveLesson),
|
||||
)
|
||||
}
|
||||
Section("О расписании") {
|
||||
@@ -290,7 +313,7 @@ private fun Section(title: String, content: @Composable () -> Unit) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SwitchRow(title: String, text: String, checked: Boolean, change: (Boolean) -> Unit) {
|
||||
private fun SwitchRow(title: String, text: String, checked: Boolean, modifier: Modifier = Modifier, change: (Boolean) -> Unit) {
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val onChange: (Boolean) -> Unit = { on ->
|
||||
haptic.performHapticFeedback(if (on) HapticFeedbackType.ToggleOn else HapticFeedbackType.ToggleOff)
|
||||
@@ -300,7 +323,7 @@ private fun SwitchRow(title: String, text: String, checked: Boolean, change: (Bo
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
// The press highlight follows the row's rounded shape instead of a hard rectangle.
|
||||
modifier = Modifier.fillMaxWidth().clip(MaterialTheme.shapes.medium).toggleable(value = checked, role = Role.Switch, onValueChange = onChange),
|
||||
modifier = modifier.fillMaxWidth().clip(MaterialTheme.shapes.medium).toggleable(value = checked, role = Role.Switch, onValueChange = onChange),
|
||||
) {
|
||||
Column(Modifier.weight(1f).padding(end = 16.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.bodyLarge)
|
||||
@@ -1,6 +1,6 @@
|
||||
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||
|
||||
package org.xtimms.psu.schedule.ui
|
||||
package org.xtimms.psu.schedule.ui.subjects
|
||||
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.staggeredgrid.items
|
||||
@@ -72,6 +72,34 @@ import org.xtimms.psu.schedule.data.shortWeekday
|
||||
import org.xtimms.psu.schedule.data.today
|
||||
import org.xtimms.psu.schedule.data.weekdayName
|
||||
import java.time.LocalTime
|
||||
import org.xtimms.psu.schedule.ui.AppNav
|
||||
import org.xtimms.psu.schedule.ui.MainViewModel
|
||||
import org.xtimms.psu.schedule.ui.MenuButton
|
||||
import org.xtimms.psu.schedule.ui.components.CenteredEmpty
|
||||
import org.xtimms.psu.schedule.ui.components.Empty
|
||||
import org.xtimms.psu.schedule.ui.components.Heading
|
||||
import org.xtimms.psu.schedule.ui.components.LocalUndo
|
||||
import org.xtimms.psu.schedule.ui.components.SubjectsSkeleton
|
||||
import org.xtimms.psu.schedule.ui.components.TimeText
|
||||
import org.xtimms.psu.schedule.ui.components.UndoArea
|
||||
import org.xtimms.psu.schedule.ui.components.appBarColors
|
||||
import org.xtimms.psu.schedule.ui.components.navBarPadding
|
||||
import org.xtimms.psu.schedule.ui.home.SectionCard
|
||||
import org.xtimms.psu.schedule.ui.lesson.LocalDisplay
|
||||
import org.xtimms.psu.schedule.ui.lesson.LocalMarks
|
||||
import org.xtimms.psu.schedule.ui.lesson.Slot
|
||||
import org.xtimms.psu.schedule.ui.lesson.SubjectDot
|
||||
import org.xtimms.psu.schedule.ui.lesson.abbreviate
|
||||
import org.xtimms.psu.schedule.ui.lesson.rememberClock
|
||||
import org.xtimms.psu.schedule.ui.lesson.slotsOn
|
||||
import org.xtimms.psu.schedule.ui.lesson.subjectColor
|
||||
import org.xtimms.psu.schedule.ui.lesson.subjectKey
|
||||
import org.xtimms.psu.schedule.ui.motion.AnimatedItem
|
||||
import org.xtimms.psu.schedule.ui.notes.LocalNotes
|
||||
import org.xtimms.psu.schedule.ui.notes.NoteEditor
|
||||
import org.xtimms.psu.schedule.ui.notes.NoteRow
|
||||
import org.xtimms.psu.schedule.ui.notes.NoteTarget
|
||||
import org.xtimms.psu.schedule.ui.lesson.statsFor
|
||||
|
||||
/** Everything the loaded weeks say about one subject. */
|
||||
private class SubjectSummary(
|
||||
@@ -406,7 +434,7 @@ private fun SubjectNotes(s: SubjectSummary, vm: MainViewModel) {
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
notes.forEach { note -> NoteRow(note, onToggle = { vm.toggleNote(note.id) }, onClick = { editing = note }, showSubject = false) }
|
||||
notes.forEach { note -> NoteRow(note, onToggle = { vm.toggleNote(note.id) }, onClick = { editing = note }, showSubject = false, onPostpone = { vm.postponeNote(note, it) }) }
|
||||
if (next != null) FilledTonalButton(onClick = { adding = true }) {
|
||||
Icon(Icons.Rounded.Add, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
@@ -0,0 +1,141 @@
|
||||
package org.xtimms.psu.schedule.ui.theme
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.ClipOp
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.drawscope.clipPath
|
||||
import androidx.compose.ui.graphics.layer.GraphicsLayer
|
||||
import androidx.compose.ui.graphics.layer.drawLayer
|
||||
import androidx.compose.ui.graphics.rememberGraphicsLayer
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.LayoutCoordinates
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import org.xtimms.psu.schedule.ui.motion.Emphasized
|
||||
import org.xtimms.psu.schedule.ui.motion.rememberReducedMotion
|
||||
import kotlin.math.hypot
|
||||
|
||||
// Switching between light and dark: the new theme spreads in a circle from the finger, as if the
|
||||
// old screen were wiped away. A picture of the old screen covers the app while the theme changes
|
||||
// under it, and a growing hole in that picture shows the new one.
|
||||
|
||||
@Stable
|
||||
class ThemeReveal internal constructor(private val layer: GraphicsLayer, private val reduced: () -> Boolean) {
|
||||
internal var picture by mutableStateOf<ImageBitmap?>(null)
|
||||
internal var center = Offset.Zero
|
||||
internal val radius = Animatable(0f)
|
||||
internal var key: Any? by mutableStateOf(null)
|
||||
|
||||
/**
|
||||
* Applies a theme change with [apply], revealing it from [at] (a point in the window). Without
|
||||
* a point, or with animations turned off in the system, the theme just changes.
|
||||
*/
|
||||
suspend fun change(at: Offset?, apply: () -> Unit) {
|
||||
if (at == null || reduced() || picture != null) return apply()
|
||||
picture = layer.toImageBitmap()
|
||||
center = at
|
||||
radius.snapTo(0f)
|
||||
val before = key
|
||||
apply()
|
||||
// The settings are saved first; the new theme comes a moment later. "Авто" may change nothing.
|
||||
val changed = withTimeoutOrNull(600) { snapshotFlow { key }.first { it != before } } != null
|
||||
if (changed) {
|
||||
// Let the new theme draw once under the picture before the hole opens.
|
||||
withFrameNanos {}
|
||||
withFrameNanos {}
|
||||
val p = picture ?: return
|
||||
val far = listOf(Offset.Zero, Offset(p.width.toFloat(), 0f), Offset(0f, p.height.toFloat()), Offset(p.width.toFloat(), p.height.toFloat()))
|
||||
.maxOf { hypot(it.x - at.x, it.y - at.y) }
|
||||
radius.animateTo(far, tween(550, easing = Emphasized))
|
||||
}
|
||||
picture = null
|
||||
}
|
||||
}
|
||||
|
||||
val LocalThemeReveal = staticCompositionLocalOf<ThemeReveal?> { null }
|
||||
|
||||
/**
|
||||
* The app under a theme reveal. [key] is the theme as drawn (dark or light, wallpaper colours):
|
||||
* when it changes, the reveal knows the new theme is in place.
|
||||
*/
|
||||
@Composable
|
||||
fun ThemeRevealHost(key: Any, content: @Composable () -> Unit) {
|
||||
val layer = rememberGraphicsLayer()
|
||||
val reduced = rememberReducedMotion()
|
||||
val reveal = remember(layer, reduced) { ThemeReveal(layer) { reduced } }
|
||||
SideEffect { reveal.key = key }
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.drawWithContent {
|
||||
layer.record { this@drawWithContent.drawContent() }
|
||||
drawLayer(layer)
|
||||
},
|
||||
) {
|
||||
CompositionLocalProvider(LocalThemeReveal provides reveal) { content() }
|
||||
}
|
||||
reveal.picture?.let { picture ->
|
||||
Canvas(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
// The old screen is only a picture: taps wait for the new one.
|
||||
.pointerInput(Unit) { awaitEachGesture { awaitFirstDown(pass = PointerEventPass.Initial).consume() } },
|
||||
) {
|
||||
val hole = Path().apply {
|
||||
addOval(androidx.compose.ui.geometry.Rect(reveal.center, reveal.radius.value))
|
||||
}
|
||||
clipPath(hole, ClipOp.Difference) { drawImage(picture) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Where the finger last went down on an element, in the window: the centre of a theme reveal. */
|
||||
class TapPoint {
|
||||
var at: Offset? = null
|
||||
private set
|
||||
private var coordinates: LayoutCoordinates? = null
|
||||
|
||||
internal fun place(c: LayoutCoordinates) {
|
||||
coordinates = c
|
||||
}
|
||||
|
||||
internal fun down(local: Offset) {
|
||||
at = coordinates?.takeIf { it.isAttached }?.localToRoot(local)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberTapPoint(): TapPoint = remember { TapPoint() }
|
||||
|
||||
fun Modifier.tapPoint(point: TapPoint): Modifier = this
|
||||
.onGloballyPositioned(point::place)
|
||||
.pointerInput(point) {
|
||||
awaitEachGesture {
|
||||
point.down(awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial).position)
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,7 @@ 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 org.xtimms.psu.schedule.ui.lesson.abbreviate
|
||||
import java.time.LocalDate
|
||||
|
||||
/** A day of the week as the widget shows it. */
|
||||
|
||||
@@ -27,7 +27,7 @@ export const lessonLine = (l) =>
|
||||
// "Занятия в Полоцке": a day of a group or subgroup in another town, not a lesson.
|
||||
const placeLine = (p) =>
|
||||
[
|
||||
`@ ${p.date || DAYS[p.day]}`,
|
||||
`@ ${p.date || DAYS[p.day]}${p.from ? ` с ${p.from}` : ""}`,
|
||||
p.text,
|
||||
[...p.groups, ...p.subgroups.map((s) => `${s.group}/${s.label.replace("Подгруппа ", "п")}`)].join(","),
|
||||
].join(" | ");
|
||||
|
||||
@@ -5,7 +5,8 @@ import { roomMoves } from "./rooms.js";
|
||||
// whole text as the subject. These checks find such cells after parsing, so a new format or a
|
||||
// typo in Word shows up as soon as the document is published instead of when a student notices.
|
||||
|
||||
const MONTHS = /(?<![а-яё])(?:январ|феврал|март|апрел|ма[йя]|июн|июл|август|сентябр|октябр|ноябр|декабр)[а-яё]*/iu;
|
||||
// "мая" is a month, "Майнор" is not.
|
||||
const MONTHS = /(?<![а-яё])(?:(?:январ|феврал|март|апрел|июн|июл|август|сентябр|октябр|ноябр|декабр)[а-яё]*|ма[йя](?![а-яё]))/iu;
|
||||
// The longest semester has about twenty weeks; "27н." is a typo, not a week.
|
||||
const LAST_WEEK = 24;
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import { parseWeekRanges, parseTextDates, DATE_LIST, weekday } from "../shared/w
|
||||
import { roomMoves, onlyPlace, weekPlan, partsByWeek } from "./rooms.js";
|
||||
import { placeNote, lessonPlace, placeOf } from "./places.js";
|
||||
const run = promisify(execFile);
|
||||
export const PARSER_VERSION = 18;
|
||||
export const PARSER_VERSION = 19;
|
||||
const days = [
|
||||
"понедельник",
|
||||
"вторник",
|
||||
@@ -20,10 +20,17 @@ const days = [
|
||||
"суббота",
|
||||
"воскресенье",
|
||||
];
|
||||
const clean = (value) =>
|
||||
export const clean = (value) =>
|
||||
value
|
||||
.replace(/\u00a0/g, " ")
|
||||
// "Ф и з и ч е с к а я к у л ь т у р а": letters spaced out for show, words apart by
|
||||
// several spaces. Joined back before the spaces collapse: "Физическая культура".
|
||||
.replace(/(?<![\p{L}\d])(?:\p{L} ){3,}\p{L}(?![\p{L}\d])/gu, (word) => word.replace(/ /g, ""))
|
||||
// "со 2 по 14 нед.": the usual "2-14 нед.".
|
||||
.replace(/(?<![\p{L}\d])(?:с|со)\s*(\d{1,2})\s*по\s*(\d{1,2})(?=\s*н(?:ед)?\.?)/giu, "$1-$2")
|
||||
.replace(/[\t ]+/g, " ")
|
||||
.replace(/«\s+/g, "«")
|
||||
.replace(/\s+»/g, "»")
|
||||
.replace(/\s*\n\s*/g, "\n")
|
||||
.trim();
|
||||
// A group is a two-digit year and a capitalised abbreviation: "23-БА", "24-ДОи", "23-ИНС-1",
|
||||
@@ -595,7 +602,9 @@ export function parseDocx(buffer, source = {}) {
|
||||
date = null,
|
||||
time = null,
|
||||
pair = null,
|
||||
timeCol = null;
|
||||
timeCol = null,
|
||||
// The day's first lesson time: a town note further down holds from its own row on.
|
||||
dayStart = null;
|
||||
// Session schedules use separate room, subject and teacher columns.
|
||||
const session = rows[header].some((cell) => /пара/i.test(cell.text));
|
||||
let sessionColumns = null;
|
||||
@@ -625,6 +634,7 @@ export function parseDocx(buffer, source = {}) {
|
||||
: null;
|
||||
time = null;
|
||||
pair = null;
|
||||
dayStart = null;
|
||||
}
|
||||
if (!day) continue;
|
||||
// Credits and exams give only the start ("8.30"); the bell table supplies the end.
|
||||
@@ -637,6 +647,7 @@ export function parseDocx(buffer, source = {}) {
|
||||
pair = session ? row.find((c) => c.col === 1)?.text : null;
|
||||
}
|
||||
if (!time) continue;
|
||||
dayStart ??= time.start;
|
||||
seenRows++;
|
||||
const continuedTime =
|
||||
row.find((c) => c.col === timeCol)?.merge === "continue";
|
||||
@@ -667,7 +678,9 @@ export function parseDocx(buffer, source = {}) {
|
||||
// are in that town. A lesson may follow it in the same cell.
|
||||
const note = placeNote(raw);
|
||||
if (note) {
|
||||
places.push({ day, date, groups: targetGroups, subgroups: columnSubgroups, place: note.place, text: note.text });
|
||||
// "Занятия в Полоцке" in the 13:40 row of 25-ИТ: the morning is in Novopolotsk.
|
||||
const from = time.start > dayStart ? time.start : null;
|
||||
places.push({ day, date, groups: targetGroups, subgroups: columnSubgroups, place: note.place, text: note.text, ...(from && { from }) });
|
||||
raw = note.rest;
|
||||
if (!raw.trim()) return;
|
||||
}
|
||||
|
||||
@@ -64,7 +64,12 @@ export function placeOf(lesson, places, labelsOf) {
|
||||
let found = null;
|
||||
for (const group of lesson.groups) {
|
||||
const notes = places.filter(
|
||||
(p) => p.day === lesson.day && (!p.date || p.date === lesson.date) && p.groups.includes(group),
|
||||
(p) =>
|
||||
p.day === lesson.day &&
|
||||
(!p.date || p.date === lesson.date) &&
|
||||
p.groups.includes(group) &&
|
||||
// A note from the middle of the day holds from its row on.
|
||||
(!p.from || lesson.start >= p.from),
|
||||
);
|
||||
if (!notes.length) return null;
|
||||
const label = lesson.subgroups.find((s) => s.group === group)?.label;
|
||||
|
||||
@@ -9,7 +9,13 @@
|
||||
export function placesOn(places, group, labels, day, date = null) {
|
||||
const on = (places || [])
|
||||
.filter((p) => p.groups.includes(group) && p.day === day && (!p.date || p.date === date))
|
||||
.map((p) => ({ place: p.place, text: p.text, subgroup: p.subgroups.find((s) => s.group === group)?.label || null }));
|
||||
.map((p) => ({
|
||||
place: p.place,
|
||||
text: p.text,
|
||||
subgroup: p.subgroups.find((s) => s.group === group)?.label || null,
|
||||
// "13:40": the note holds from that lesson on, not the whole day.
|
||||
...(p.from && { from: p.from }),
|
||||
}));
|
||||
const whole = on.filter((p) => !p.subgroup);
|
||||
for (const p of on)
|
||||
if (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { groupsIn, alternatives, entries, details, leadingTime, parallel, audienceOf } from "../server/parser.js";
|
||||
import { groupsIn, alternatives, entries, details, leadingTime, parallel, audienceOf, clean } from "../server/parser.js";
|
||||
import { placeOf } from "../server/places.js";
|
||||
import { roomMoves, weekPlan, onlyPlace } from "../server/rooms.js";
|
||||
import { peopleAndRooms, roomsOn, teachersOn, movedOn } from "../server/directory.js";
|
||||
|
||||
@@ -161,6 +162,20 @@ test("Вид занятия после преподавателя: фамили
|
||||
assert.equal(details("(л.з.) Алексеева Т.А. ауд. 33н").subject, "(л.з.) Алексеева Т.А. ауд. 33н");
|
||||
});
|
||||
|
||||
test("Разрядка, «со 2 по 14 нед.», город с середины дня", () => {
|
||||
// Letters spaced out for show, words apart by several spaces.
|
||||
assert.equal(clean("Ф и з и ч е с к а я к у л ь т у р а"), "Физическая культура");
|
||||
assert.equal(clean("З а н я т и я в П о л о ц к е"), "Занятия в Полоцке");
|
||||
assert.equal(clean("со 2 по 14 нед. М о д у л ь « М а й н о р »"), "2-14 нед. Модуль «Майнор»");
|
||||
// Initials and short words stay as they are.
|
||||
assert.equal(clean("Борун Е.Н. ауд. 100"), "Борун Е.Н. ауд. 100");
|
||||
// "Занятия в Полоцке" in the 13:40 row: the morning is not in Polotsk.
|
||||
const places = [{ day: 5, date: null, groups: ["25-ИТ-1"], subgroups: [], place: "Полоцк", from: "13:40" }];
|
||||
const at = (start) => placeOf({ day: 5, date: null, start, groups: ["25-ИТ-1"], subgroups: [] }, places, () => []);
|
||||
assert.equal(at("08:30"), null);
|
||||
assert.equal(at("15:20"), "Полоцк");
|
||||
});
|
||||
|
||||
test("Старое имя разделённой группы: понятная ошибка, другое написание — та же группа", async () => {
|
||||
const { timetable } = await import("../server/timetable.js");
|
||||
const schedule = { groups: ["24-ИЯ (РА)и-1", "24-ИЯ (РА)и-2"], lessons: [], notes: [], period: "Осенний семестр", source: {} };
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
вт 11:45–13:10 | каждую неделю, нижняя строка | Стандартизация программного обеспечения | Лекция | 321Д | Михнович О.В. | 24-ИТ-1,24-ИТ-2
|
||||
вт 13:40–15:05 | каждую неделю, верхняя строка | Метрология, стандартизация и сертификация (в информационных технологиях | Практика | 321Д | Михнович О.В. | 24-ИТ-1,24-ИТ-2
|
||||
вт 13:40–15:05 | каждую неделю, нижняя строка | Стандартизация программного обеспечения | Практика | 321Д | Михнович О.В. | 24-ИТ-1,24-ИТ-2
|
||||
вт 15:20–16:45 | каждую неделю | Ф И З И Ч Е С К А Я К У Л Ь Т У Р А | Занятие | | | 24-ИТ-1,24-ИТ-2
|
||||
вт 15:20–16:45 | каждую неделю | ФИЗИЧЕСКАЯ КУЛЬТУРА | Занятие | | | 24-ИТ-1,24-ИТ-2
|
||||
пн 08:30–09:55 | каждую неделю, верхняя строка | Объектно-ориентированные технологии програм-мирования и стандарты проектирования | Лабораторная | 311А | Гордевич Д.А. | 24-ИТ-1,24-ИТ-1/п3
|
||||
пн 08:30–09:55 | каждую неделю, верхняя строка | Объектно-ориентированные технологии програм-мирования и стандарты проектирования | Лабораторная | 311А | Гордевич Д.А. | 24-ИТ-2,24-ИТ-2/п3
|
||||
пн 08:30–09:55 | каждую неделю, нижняя строка | Системное программирование | Лабораторная | 207Г | Юрчишко Е.И. | 24-ИТ-1,24-ИТ-1/п4
|
||||
@@ -79,10 +79,10 @@
|
||||
сб 11:45–13:10 | даты 2026-09-12,2026-09-26,2026-10-10,2026-10-24, нижняя строка | Основы информационной безопасности | Практика | 321Д | Пастухов Ю.Ф. | 24-ИТ-2
|
||||
сб 11:45–13:10 | каждую неделю, верхняя строка | Основы информационной безопасности | Практика | 321Д | Пастухов Ю.Ф. | 24-ИТ-1
|
||||
сб 11:45–13:10 | каждую неделю, верхняя строка | Основы информационной безопасности | Практика | 321Д | Пастухов Ю.Ф. | 24-ИТ-2
|
||||
ср 10:05–11:30 | каждую неделю | со 2 по 14 нед. М о д у л ь « М а й н о р » | Занятие | | | 24-ИТ-1,24-ИТ-2
|
||||
ср 10:05–11:30 | нед. 2-14 | Модуль «Майнор» | Занятие | | | 24-ИТ-1,24-ИТ-2
|
||||
чт 08:30–09:55 | каждую неделю, нижняя строка | Системное программирование | Лабораторная | 311А | Скуковская А.А. | 24-ИТ-1,24-ИТ-1/п2
|
||||
чт 08:30–09:55 | каждую неделю, нижняя строка | Системное программирование | Лабораторная | 311А | Скуковская А.А. | 24-ИТ-2,24-ИТ-2/п2
|
||||
чт 10:05–11:30 | каждую неделю | Ф И З И Ч Е С К А Я К У Л Ь Т У Р А | Занятие | | | 24-ИТ-1,24-ИТ-2
|
||||
чт 10:05–11:30 | каждую неделю | ФИЗИЧЕСКАЯ КУЛЬТУРА | Занятие | | | 24-ИТ-1,24-ИТ-2
|
||||
чт 11:45–13:10 | каждую неделю, верхняя строка | Базы данных | Лабораторная | 218Д | Дьякова А.С. | 24-ИТ-1,24-ИТ-1/п2
|
||||
чт 11:45–13:10 | каждую неделю, верхняя строка | Базы данных | Лабораторная | 218Д | Дьякова А.С. | 24-ИТ-2,24-ИТ-2/п2
|
||||
чт 11:45–13:10 | каждую неделю, верхняя строка | Веб-технологии | Лабораторная | 207Г | Аврусевич К.Д. | 24-ИТ-1,24-ИТ-1/п4
|
||||
|
||||
Reference in New Issue
Block a user