feat: Enhance search screen with week color transitions and new UI components
- Implemented a dynamic week color fill effect in SearchScreen to enhance user experience during week navigation. - Added a new ChangeWords.kt file to manage lesson change notifications with user-friendly messages. - Introduced CollapsingTop component for improved header behavior during scrolling. - Created Rest.kt to handle display logic for days without lessons, including holidays and free days. - Developed DayLine.kt to visualize daily lessons with a timeline representation. - Added Confetti.kt for celebratory animations during special events. - Implemented Presence.kt to manage item visibility during list transitions in the week grid.
This commit is contained in:
@@ -291,6 +291,11 @@ DOMAIN=schedule.example.by docker compose --profile https up -d --build
|
||||
- **Смена темы.** «Светлая», «Тёмная» и «Цвета обоев» в настройках меняют тему кругом, который расходится от нажатой кнопки: снимок старого экрана лежит поверх приложения, и в нём растёт дыра с новой темой (`ui/theme/ThemeReveal.kt`). Значки строки состояния рисует система, они меняются сразу.
|
||||
- **Выполненное задание.** При отметке текст зачёркивается слева направо, строка за строкой, и задание чуть задерживается на месте, прежде чем уйти к выполненным. Когда закрыто последнее открытое, у «Всё сделано» выскакивает галочка.
|
||||
- **Обновление жестом.** Потянуть вниз в «Обзоре» и в расписании: экспрессивный индикатор M3 с меняющейся формой и щелчок отклика, когда тянуть уже достаточно.
|
||||
- **Полоса дня на «Обзоре».** В карточке «Следующие занятия» — весь сегодняшний день одной линией от первой пары до последней: пары отрезками в цветах предметов (прошедшие бледнее), перерывы тонкой линией, точка «сейчас 12:20» плавно идёт по ней вместе с часами. TalkBack читает «Сегодня 5 пар с 10:05 до 18:10, сейчас идёт 2-я».
|
||||
- **Неделя меняется на месте.** Недельная сетка (в «Расписании» и на «Обзоре» планшета) больше не листается страницами: при смене недели свайпом, стрелками или «Сегодня» пары, которые есть в обеих неделях (тот же день, время, предмет и вид), остаются на месте, новые вырастают, исчезнувшие гаснут, другая аудитория у той же пары сменяется плавно, числа дат прокручиваются, а цвет шапки перетекает в цвет новой недели. Так сразу видно, чем белая неделя отличается от зелёной. Свайп ведёт сетку за пальцем и меняет неделю на одну за раз.
|
||||
- **Телефон на боку** (высота меньше 480dp, M3 compact height). Меню остаётся телефонным, открывается кнопкой, пункты стоят сразу под ней и прокручиваются; постоянная панель — только у планшета. Шапка «Расписания» (тулбар и лента дней) уезжает вверх, пока прокручивается день или неделя, и возвращается при прокрутке вниз; под строкой состояния остаётся только её цвет. Месяц «Календаря» прокручивается, если шесть недель не помещаются; пустой день, листы действий и вида планов компактнее или прокручиваются. На коротком экране вроде Pixel 1 пустой день не сжимает карточку следующей пары: страница прокручивается.
|
||||
- **Пустой день с характером** (`ui/components/Rest.kt`). «Занятий нет» говорит, почему: «Воскресенье», «Свободная суббота», «Праздник» (государственные праздники Беларуси, включая Радуницу), «До семестра N дней», «Семестр закончился — дальше сессия и каникулы» или «Свободный день». У каждого своя иконка, форма M3 Expressive и цвет, форма медленно вращается (без анимации при «Убрать анимацию»). Под пальцем форма морфится в другую (cookie — в двенадцатиугольник, вспышка — в «бум») и пружиной возвращается; даже короткий тап проигрывает «хлопок» целиком, а иконка хлопушки покачивается. Хлопушка праздника бросает конфетти на весь экран (`ui/motion/Confetti.kt`, своя реализация по мотивам Buckwheat, чей код под GPL-3.0 не переносился): бумажки разлетаются веером, кувыркаются тёмной оборотной стороной, покачиваются и опускаются против воздуха, цвета подстроены под тему. Так же в календаре «Планов», на подставке «ноутбука» и в «Обзоре».
|
||||
- **Цвет шапки за лентой дней.** Шапка «Расписания», «Планов» (повестка дня) и дня преподавателя в «Поиске» вместе с тулбаром — одно целое с лентой дней: при листании ленты её цвет плавно перетекает между цветами двух недель (белая и зелёная) по мере движения пальца, а не меняется скачком после выбора дня.
|
||||
- **Свежие изменения на виду.** Если университет перезалил документ и пары изменились или добавились, их карточки в расписании дважды вспыхивают цветом (один раз, при первом показе).
|
||||
- Первый запуск — короткий онбординг: приветствие с тремя возможностями приложения, затем шаги с волнистым индикатором прогресса: группа, подгруппа (только если группа делится на подгруппы) и напоминания. Разрешение на уведомления спрашивается на шаге напоминаний, его можно отложить; «Назад» (с предиктивным жестом) возвращает на предыдущий шаг. После онбординга открывается «Обзор» выбранной группы.
|
||||
- Обзор: отчёт за неделю (сколько пар прошло), следующие занятия сегодня с обратным отсчётом и ближайший учебный день.
|
||||
@@ -303,7 +308,7 @@ DOMAIN=schedule.example.by docker compose --profile https up -d --build
|
||||
- Обратный отсчёт до сессии в «Обзоре»: дни до ближайшего экзамена или зачёта из `/api/exams` (консультации — только в списке), по нажатию — вся сессия. Пока университет не опубликовал сессию, отсчёт идёт до конца семестра. Экзамены кэшируются на устройстве.
|
||||
- **Занятия в другом городе.** Если в Word над днём написано «Занятия в Полоцке», над парами дня появляется карточка «Занятия в Полоцке · пометка в Word на этот день» — на странице дня, в «Календаре», в «Обзоре» (сегодня и ближайший день) и на подставке «ноутбука», а в недельной сетке под датой — метка «Полоцк». Если заметка относится к одной подгруппе, а выбраны все, в карточке указана подгруппа. В карточках пар город стоит рядом с аудиторией («249Ж · Полоцк»), в подробностях — строка «Город»; так же — в виджетах, плитке и напоминаниях. Раньше такая заметка показывалась отдельной парой «Занятия в Полоцке».
|
||||
- **Группа, которой больше нет.** Если сервер отвечает `group_not_found` или `document_not_found` — группу разделили («24‑ИЯ(РА)» стала «24‑ИЯ (РА)и‑1»…), или документ ушёл из каталога, — плашка с текстом сервера предлагает «Выбрать группу». Сохранённая копия остаётся на экране, а устаревшая группа убирается из недавних.
|
||||
- **Изменения в расписании.** Как в PWA: когда университет перезаливает документ группы, приложение сравнивает новое расписание с копией на устройстве. В «Обзоре» появляется карточка «Расписание обновлено · N изменений» с кнопкой «Показать», а в списке видно, что изменилось, что добавлено и что удалено, например «Аудитория: 374 → 375». Карточка показывается до просмотра или закрытия, последний список остаётся в настройках («Последние изменения»). Сравнение срабатывает и при фоновом обновлении (`SyncWorker`, раз в 6 часов). Если изменения нашлись в фоне, приходит уведомление «Расписание 23-АРХ изменилось» с первыми изменениями, например «Проектирование общественных зданий: аудитория 374 → 375». Уведомление локальное, сервер push для него не нужен. Его можно выключить в настройках («Изменения в расписании», по умолчанию включено), а на Android 13+ нужно разрешение на уведомления. Считается только новая версия того же файла: смена версии парсера или переход на другой документ изменениями не считаются. Сравниваются строки Word по дням недели и разовые занятия со своей датой, а не каждая рассчитанная дата.
|
||||
- **Изменения в расписании.** Как в PWA: когда университет перезаливает документ группы, приложение сравнивает новое расписание с копией на устройстве. В «Обзоре» появляется карточка «Расписание обновлено · N изменений» с кнопкой «Показать», а в списке каждое изменение сказано фразой (`data/ChangeWords.kt`): «Бизнес-анализ в четверг перенесли из 430 в 326», «Политологию перенесли с понедельника на среду», «Риск-менеджмент во вторник ведёт Иванов И.И. вместо Петрова П.П.», «Добавлена пара: Политология по субботам в 08:30, ауд. 129», «Убрана пара: …». У каждого — «В расписании»: открывает ближайший день, где эта пара стоит. Карточка показывается до просмотра или закрытия, последний список остаётся в настройках («Последние изменения»). Сравнение срабатывает и при фоновом обновлении (`SyncWorker`, раз в 6 часов). Если изменения нашлись в фоне, приходит уведомление «Расписание 23-АРХ изменилось» с первыми изменениями теми же фразами, например «Проектирование общественных зданий в среду перенесли из 374 в 375». Уведомление локальное, сервер push для него не нужен. Его можно выключить в настройках («Изменения в расписании», по умолчанию включено), а на Android 13+ нужно разрешение на уведомления. Считается только новая версия того же файла: смена версии парсера или переход на другой документ изменениями не считаются. Сравниваются строки Word по дням недели и разовые занятия со своей датой, а не каждая рассчитанная дата.
|
||||
- Задания и заметки к паре: из карточки занятия — к этой паре или к следующей паре того же предмета. Задания отмечаются выполненными, просроченные подсвечиваются; список — в «Обзоре», по предмету — в «Предметах», точка на блоке в недельной сетке. Хранятся только на устройстве (`notes.json`) и привязаны к группе.
|
||||
- **Фото к заданиям:** в редакторе задания или заметки — «Снять» (приложение камеры) и «Галерея» (системный выбор фото, без доступа к памяти), до 6 фото. Фото уменьшается до 2048 px по длинной стороне, поворачивается по EXIF и хранится в `files/photos/` рядом с заметками. Удаление фото, задания или отмена правки убирает файлы, а при запуске удаляются фото, на которые не ссылается ни одна заметка. В списках у задания миниатюра (с «+N»), на карточке пары — значок фото. Просмотр — на весь экран, с листанием, масштабом щипком и двойным касанием. Он открывается по M3 как container transform для «героического» момента: фото вырастает из миниатюры (в строке задания или в редакторе) из квадратной обрезки в целый снимок, углы выпрямляются, вокруг поднимается чёрный фон, а при закрытии (крестик, «Назад», предиктивный жест) фото сжимается обратно в свою миниатюру; у фото без своей миниатюры — в ту, из которой открыли, и на последнем отрезке перетекает в её снимок. Миниатюра запоминает место на экране, поэтому переход работает и из диалога редактора. Задание может быть и одним фото, без текста.
|
||||
- Посещаемость: «был» или «пропустил» в карточке занятия и одним касанием в «Обзоре» для прошедших сегодня пар; статистика по каждому предмету в «Предметах». Хранится на устройстве (`attendance.json`).
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -140,6 +140,10 @@ 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
|
||||
import org.xtimms.psu.schedule.ui.motion.ConfettiHost
|
||||
import org.xtimms.psu.schedule.ui.motion.ConfettiOverlay
|
||||
import org.xtimms.psu.schedule.ui.motion.LocalConfetti
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
|
||||
private enum class Tab(val label: String, val icon: ImageVector, val selectedIcon: ImageVector) {
|
||||
Home("Обзор", Icons.Outlined.Home, Icons.Rounded.Home),
|
||||
@@ -236,6 +240,9 @@ private val MEDIUM_WIDTH = 600.dp
|
||||
/** From the expanded window class on (tablets in landscape) the menu stays open, as in School Planner. */
|
||||
private val EXPANDED_WIDTH = 840.dp
|
||||
|
||||
/** Below this height (M3 compact height: a phone in landscape) the menu does not fit a rail. */
|
||||
private val COMPACT_HEIGHT = 480.dp
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
private fun App(vm: MainViewModel, firstRun: Boolean, launch: Launch?, onLaunched: () -> Unit) {
|
||||
@@ -292,17 +299,20 @@ private fun App(vm: MainViewModel, firstRun: Boolean, launch: Launch?, onLaunche
|
||||
// Behind the pushed-aside screen: the menu's own card-white background.
|
||||
// "Задание удалено · Вернуть" and the like, over every screen (Undo.kt).
|
||||
val morph = remember { MorphHost() }
|
||||
val confetti = remember { ConfettiHost() }
|
||||
UndoArea(Modifier.fillMaxSize()) {
|
||||
androidx.compose.runtime.CompositionLocalProvider(LocalMorph provides morph) {
|
||||
androidx.compose.runtime.CompositionLocalProvider(LocalMorph provides morph, LocalConfetti provides confetti) {
|
||||
// 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
|
||||
// A phone on its side is wide but short: seven menu items do not fit a rail there, so
|
||||
// it keeps the phone's menu, opened from the bar.
|
||||
val wide = maxWidth >= MEDIUM_WIDTH && maxHeight >= COMPACT_HEIGHT
|
||||
// An open book-style foldable gets the tablet layout; a flip phone keeps the phone one.
|
||||
val foldTablet = LocalHinge.current != null && minOf(maxWidth, maxHeight) >= MEDIUM_WIDTH
|
||||
// On a foldable the menu stays a narrow rail and opens over the screen, so the page
|
||||
// beside it is never squeezed; a tablet in landscape keeps it open beside the screen.
|
||||
val permanent = maxWidth >= EXPANDED_WIDTH && !foldTablet
|
||||
val permanent = maxWidth >= EXPANDED_WIDTH && maxHeight >= COMPACT_HEIGHT && !foldTablet
|
||||
val weekView = weekChoice ?: (permanent || foldTablet)
|
||||
BackHandler(enabled = wide && !permanent && (rail.targetValue == WideNavigationRailValue.Expanded)) { scope.launch { rail.collapse() } }
|
||||
val nav = remember(wide) {
|
||||
@@ -553,6 +563,8 @@ 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)
|
||||
// A holiday's party popper throws its confetti over the whole app.
|
||||
ConfettiOverlay(confetti)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -581,27 +593,24 @@ private fun MenuRail(
|
||||
onPicked: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
WideNavigationRail(
|
||||
modifier = modifier,
|
||||
state = state,
|
||||
colors = WideNavigationRailDefaults.colors(containerColor = color),
|
||||
// On a landscape phone the items need every bit of height; they scroll when it runs out.
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
header = {
|
||||
// A 64dp row like the top app bar's, so the button sits level with the screen
|
||||
// title; 24dp in, so its icon lines up with the item icons below in both states.
|
||||
Box(Modifier.height(64.dp).padding(start = 24.dp), contentAlignment = Alignment.CenterStart) {
|
||||
IconButton(onClick = onToggle) {
|
||||
Icon(
|
||||
if (expanded) Icons.AutoMirrored.Rounded.MenuOpen else Icons.Rounded.Menu,
|
||||
contentDescription = if (expanded) "Закрыть меню" else "Открыть меню",
|
||||
)
|
||||
}
|
||||
// A phone on its side: the rail puts a 40dp gap under its header, more than the height
|
||||
// allows, so there the items go in the header, right under the button.
|
||||
val short = androidx.compose.ui.platform.LocalConfiguration.current.screenHeightDp < COMPACT_HEIGHT.value
|
||||
val button: @Composable () -> Unit = {
|
||||
// A 64dp row like the top app bar's, so the button sits level with the screen
|
||||
// title; 24dp in, so its icon lines up with the item icons below in both states.
|
||||
Box(Modifier.height(64.dp).padding(start = 24.dp), contentAlignment = Alignment.CenterStart) {
|
||||
IconButton(onClick = onToggle) {
|
||||
Icon(
|
||||
if (expanded) Icons.AutoMirrored.Rounded.MenuOpen else Icons.Rounded.Menu,
|
||||
contentDescription = if (expanded) "Закрыть меню" else "Открыть меню",
|
||||
)
|
||||
}
|
||||
},
|
||||
) {
|
||||
}
|
||||
}
|
||||
val list: @Composable (Modifier) -> Unit = { listModifier ->
|
||||
Column(
|
||||
Modifier.verticalScroll(rememberScrollState()),
|
||||
listModifier.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
horizontalAlignment = if (expanded) Alignment.Start else Alignment.CenterHorizontally,
|
||||
) {
|
||||
@@ -637,6 +646,16 @@ private fun MenuRail(
|
||||
}
|
||||
}
|
||||
}
|
||||
WideNavigationRail(
|
||||
modifier = modifier,
|
||||
state = state,
|
||||
colors = WideNavigationRailDefaults.colors(containerColor = color),
|
||||
// On a landscape phone the items need every bit of height; they scroll when it runs out.
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
header = if (short) ({ Column { button(); list(Modifier.navigationBarsPadding()) } }) else button,
|
||||
) {
|
||||
if (!short) list(Modifier)
|
||||
}
|
||||
}
|
||||
|
||||
private val MENU_DAY = listOf(Tab.Home, Tab.Plans, Tab.Calendar, Tab.Schedule)
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package org.xtimms.psu.schedule.data
|
||||
|
||||
import java.time.LocalDate
|
||||
|
||||
// What changed, said the way a student would say it: "Бизнес-анализ в четверг перенесли из 430
|
||||
// в 326", "Добавлена пара: Политология по субботам в 08:30". Used by the changes sheet and the
|
||||
// notification, over the field differences of Changes.kt.
|
||||
|
||||
private val ACCUSATIVE = listOf("понедельник", "вторник", "среду", "четверг", "пятницу", "субботу", "воскресенье")
|
||||
private val GENITIVE = listOf("понедельника", "вторника", "среды", "четверга", "пятницы", "субботы", "воскресенья")
|
||||
private val DATIVE_PLURAL = listOf("понедельникам", "вторникам", "средам", "четвергам", "пятницам", "субботам", "воскресеньям")
|
||||
|
||||
private const val NONE = "не указано"
|
||||
|
||||
/** "в четверг", "во вторник", or "12 ноября" for a lesson printed with its date. */
|
||||
private fun ChangedLesson.on(): String = date?.let { parseDate(it).longLabel() }
|
||||
?: ((if (day == 2) "во " else "в ") + ACCUSATIVE[day - 1])
|
||||
|
||||
/** "с понедельника" / "с 5 ноября". */
|
||||
private fun ChangedLesson.fromDay(): String = "с " + (date?.let { parseDate(it).longLabel() } ?: GENITIVE[day - 1])
|
||||
|
||||
/** "на четверг" / "на 12 ноября". */
|
||||
private fun ChangedLesson.toDay(): String = "на " + (date?.let { parseDate(it).longLabel() } ?: ACCUSATIVE[day - 1])
|
||||
|
||||
/** "по четвергам в 11:45" / "12 ноября в 11:45". */
|
||||
private fun ChangedLesson.slot(): String =
|
||||
(date?.let { parseDate(it).longLabel() } ?: "по " + DATIVE_PLURAL[day - 1]) + " в " + lesson.start
|
||||
|
||||
/** One sentence per field that changed: "в четверг перенесли из 430 в 326". */
|
||||
fun LessonChange.sentences(): List<String> {
|
||||
val subject = after.lesson.subject
|
||||
val on = after.on()
|
||||
val moved = fields.any { it.label == "День" }
|
||||
val started = fields.any { it.label == "Начало" }
|
||||
val lines = fields.mapNotNull { f ->
|
||||
val from = f.from
|
||||
val to = f.to
|
||||
when (f.label) {
|
||||
"День" -> "$subject перенесли ${before.fromDay()} ${after.toDay()}" + if (!started) ", в ${after.lesson.start}" else ""
|
||||
"Начало" -> "$subject $on начинается в $to вместо $from"
|
||||
// A new start moves the end along; it is news only on its own.
|
||||
"Окончание" -> if (started) null else "$subject $on заканчивается в $to вместо $from"
|
||||
"Недели" -> when {
|
||||
to == NONE -> "$subject $on теперь каждую неделю, а не только $from"
|
||||
from == NONE -> "$subject $on теперь только $to"
|
||||
else -> "$subject $on теперь $to вместо $from"
|
||||
}
|
||||
"Вид занятия" -> "$subject $on теперь " + to.lowercase() + if (from != NONE) ", а не " + from.lowercase() else ""
|
||||
"Преподаватель" -> when {
|
||||
to == NONE -> "У пары «$subject» $on преподаватель больше не указан"
|
||||
from == NONE -> "$subject $on ведёт $to"
|
||||
else -> "$subject $on ведёт $to вместо $from"
|
||||
}
|
||||
"Аудитория" -> when {
|
||||
to == NONE -> "$subject $on: аудитория больше не указана"
|
||||
from == NONE -> "$subject $on будет в $to"
|
||||
else -> "$subject $on перенесли из $from в $to"
|
||||
}
|
||||
"Город" -> when {
|
||||
to == NONE -> "$subject $on: город больше не указан (был $from)"
|
||||
from == NONE -> "$subject $on: город — $to"
|
||||
else -> "$subject $on: город — $to, а не $from"
|
||||
}
|
||||
else -> "$subject $on: ${f.label.lowercase()} $to вместо $from"
|
||||
}
|
||||
}
|
||||
// A moved lesson's day says enough; its other fields follow it in the same card.
|
||||
return lines.ifEmpty { listOf("$subject $on: изменилась запись Word") }
|
||||
.let { if (moved) it.sortedBy { line -> if (line.contains("перенесли с")) 0 else 1 } else it }
|
||||
}
|
||||
|
||||
/** "Добавлена пара: Политология по субботам в 08:30, ауд. 129". */
|
||||
fun ChangedLesson.addedSentence(): String =
|
||||
"Добавлена пара: ${lesson.subject} ${slot()}" + (lesson.condition.takeIf { it.isNotBlank() }?.let { " ($it)" } ?: "") +
|
||||
(withPlace(lesson.rooms.joinToString(", ").takeIf { it.isNotEmpty() }?.let { "ауд. $it" }, lesson.place)?.let { ", $it" } ?: "")
|
||||
|
||||
/** "Убрана пара: Политология по субботам в 08:30". */
|
||||
fun ChangedLesson.removedSentence(): String = "Убрана пара: ${lesson.subject} ${slot()}"
|
||||
|
||||
/**
|
||||
* The day to show a changed row on: its own date, or the next date of its weekday from today
|
||||
* on that has the lesson (or, for a removed one, just the next such weekday).
|
||||
*/
|
||||
fun Timetable.dayOf(row: ChangedLesson, from: LocalDate = today()): LocalDate? {
|
||||
row.date?.let { return parseDate(it) }
|
||||
val ahead = days.map { parseDate(it.date) to it }.filter { (d, _) -> d >= from && d.dayOfWeek.value == row.day }
|
||||
return ahead.firstOrNull { (_, d) -> d.lessons.any { it.key == row.lesson.key } }?.first ?: ahead.firstOrNull()?.first
|
||||
}
|
||||
@@ -10,11 +10,11 @@ import androidx.core.app.NotificationManagerCompat
|
||||
import org.xtimms.psu.schedule.MainActivity
|
||||
import org.xtimms.psu.schedule.R
|
||||
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.home.changeDate
|
||||
import org.xtimms.psu.schedule.ui.home.changesLabel
|
||||
import org.xtimms.psu.schedule.data.sentences
|
||||
import org.xtimms.psu.schedule.data.addedSentence
|
||||
import org.xtimms.psu.schedule.data.removedSentence
|
||||
|
||||
/**
|
||||
* A notification when the background sync finds that the university republished the group's
|
||||
@@ -34,13 +34,11 @@ object ChangeAlerts {
|
||||
context.getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
/** "Проектирование общественных зданий: аудитория 374 → 375", up to [max] lines. */
|
||||
/** "Бизнес-анализ в четверг перенесли из 430 в 326", up to [max] lines. */
|
||||
fun summary(record: ScheduleChanges, max: Int = 4): List<String> {
|
||||
val lines = record.changed.map { c ->
|
||||
c.after.lesson.subject + ": " + c.fields.joinToString(", ") { "${it.label.lowercase()} ${it.from} → ${it.to}" }
|
||||
.ifEmpty { "изменилась запись Word" }
|
||||
} + record.added.map { "Добавлено: ${it.lesson.subject}, ${it.date?.let { d -> parseDate(d).longLabel() } ?: dayName(it.day).lowercase()} ${it.lesson.start}" } +
|
||||
record.removed.map { "Удалено: ${it.lesson.subject}" }
|
||||
val lines = record.changed.flatMap { it.sentences() } +
|
||||
record.added.map { it.addedSentence() } +
|
||||
record.removed.map { it.removedSentence() }
|
||||
return if (lines.size > max) lines.take(max - 1) + "И ещё ${lines.size - max + 1}" else lines
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package org.xtimms.psu.schedule.ui.components
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* A phone on its side: its height (M3 compact, under 480dp) leaves the content little room
|
||||
* under a tall header.
|
||||
*/
|
||||
@Composable
|
||||
fun shortScreen(): Boolean = LocalConfiguration.current.screenHeightDp < 480
|
||||
|
||||
/**
|
||||
* A [header] over [content] that, when [enabled], slides away as the content scrolls up and
|
||||
* comes back as soon as it scrolls down, as an "enter always" top app bar does. The status
|
||||
* bar's height of it always stays, so nothing scrolls under the clock.
|
||||
*/
|
||||
@Composable
|
||||
fun CollapsingTop(
|
||||
enabled: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
barColor: () -> Color = { Color.Transparent },
|
||||
header: @Composable () -> Unit,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
var offset by remember { mutableFloatStateOf(0f) }
|
||||
var range by remember { mutableIntStateOf(0) }
|
||||
val on by rememberUpdatedState(enabled)
|
||||
LaunchedEffect(enabled) { if (!enabled) offset = 0f }
|
||||
val connection = remember {
|
||||
object : NestedScrollConnection {
|
||||
private fun move(dy: Float): Offset {
|
||||
val next = (offset + dy).coerceIn(-range.toFloat(), 0f)
|
||||
val used = next - offset
|
||||
offset = next
|
||||
return Offset(0f, used)
|
||||
}
|
||||
|
||||
// The header moves first either way: away as the content scrolls up, back at once as
|
||||
// it scrolls down, wherever the content is.
|
||||
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset =
|
||||
if (on) move(available.y) else Offset.Zero
|
||||
}
|
||||
}
|
||||
val bar = WindowInsets.statusBars.getTop(LocalDensity.current)
|
||||
Box(modifier) {
|
||||
Layout(
|
||||
content = {
|
||||
Box { header() }
|
||||
Box { content() }
|
||||
},
|
||||
modifier = Modifier.nestedScroll(connection).clipToBounds(),
|
||||
) { measurables, constraints ->
|
||||
val top = measurables[0].measure(constraints.copy(minHeight = 0, maxHeight = Constraints.Infinity))
|
||||
range = (top.height - bar).coerceAtLeast(0)
|
||||
val shift = offset.roundToInt().coerceIn(-range, 0)
|
||||
val shown = top.height + shift
|
||||
val rest = (constraints.maxHeight - shown).coerceAtLeast(0)
|
||||
val body = measurables[1].measure(constraints.copy(minHeight = rest, maxHeight = rest))
|
||||
layout(constraints.maxWidth, constraints.maxHeight) {
|
||||
body.place(0, shown)
|
||||
top.place(0, shift)
|
||||
}
|
||||
}
|
||||
// Under the status bar only the header's colour shows, not the bottom of its strip.
|
||||
if (enabled) Canvas(Modifier.fillMaxWidth().height(with(LocalDensity.current) { bar.toDp() })) {
|
||||
if (offset < 0f) drawRect(barColor())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,42 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.graphics.shapes.RoundedPolygon
|
||||
import org.xtimms.psu.schedule.ui.motion.rememberReducedMotion
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.snap
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||
import androidx.compose.material3.toPath
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Matrix
|
||||
import androidx.compose.ui.graphics.Outline
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.layout.boundsInWindow
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.graphics.shapes.Morph
|
||||
import org.xtimms.psu.schedule.ui.motion.LocalConfetti
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.keyframes
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun Banner(
|
||||
@@ -101,8 +137,11 @@ fun CenteredEmpty(
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty state in the RideBus style: an icon on a seven-sided cookie shape, a title,
|
||||
* a calm explanation and an optional tonal action.
|
||||
* Empty state in the RideBus style: an icon on an expressive shape (a seven-sided cookie by
|
||||
* default), a title, a calm explanation and an optional tonal action. With [spin] the shape
|
||||
* turns slowly behind the icon, unless the system asks for less motion. With [pressed] the
|
||||
* shape is a button: under the finger it morphs into that shape and springs back, and
|
||||
* [onTap] runs ("Хлопушка" for a holiday's confetti).
|
||||
*/
|
||||
@Composable
|
||||
fun Empty(
|
||||
@@ -110,20 +149,93 @@ fun Empty(
|
||||
text: String,
|
||||
icon: ImageVector = Icons.Rounded.EventBusy,
|
||||
action: Pair<String, () -> Unit>? = null,
|
||||
shape: RoundedPolygon = MaterialShapes.Cookie7Sided,
|
||||
tone: Rest.Tone = Rest.Tone.Primary,
|
||||
spin: Boolean = false,
|
||||
pressed: RoundedPolygon? = null,
|
||||
tapLabel: String? = null,
|
||||
onTap: (() -> Unit)? = null,
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val (fill, ink) = when (tone) {
|
||||
Rest.Tone.Primary -> scheme.primaryContainer to scheme.onPrimaryContainer
|
||||
Rest.Tone.Secondary -> scheme.secondaryContainer to scheme.onSecondaryContainer
|
||||
Rest.Tone.Tertiary -> scheme.tertiaryContainer to scheme.onTertiaryContainer
|
||||
}
|
||||
val reduced = rememberReducedMotion()
|
||||
val angle = if (spin && !reduced) {
|
||||
val turn = rememberInfiniteTransition(label = "empty")
|
||||
turn.animateFloat(0f, 360f, infiniteRepeatable(tween(24_000, easing = LinearEasing)), label = "angle").value
|
||||
} else 0f
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val press = remember { MutableInteractionSource() }
|
||||
val down by press.collectIsPressedAsState()
|
||||
// Pressed, the shape morphs on a stiff spring; let go, it bounces back.
|
||||
val hold by animateFloatAsState(
|
||||
if (down && pressed != null) 1f else 0f,
|
||||
if (reduced) snap() else if (down) spring(dampingRatio = 0.6f, stiffness = Spring.StiffnessMedium) else spring(dampingRatio = 0.35f, stiffness = Spring.StiffnessMediumLow),
|
||||
label = "morph",
|
||||
)
|
||||
// A quick tap is over before a hold shows anything: each tap plays the whole pop instead,
|
||||
// the shape there and back on springs, the icon rocking like a fired party popper.
|
||||
val pop = remember { Animatable(0f) }
|
||||
val wobble = remember { Animatable(0f) }
|
||||
val scope = rememberCoroutineScope()
|
||||
fun popNow() {
|
||||
if (reduced) return
|
||||
scope.launch {
|
||||
pop.snapTo(maxOf(pop.value, hold))
|
||||
pop.animateTo(1f, spring(dampingRatio = 0.6f, stiffness = Spring.StiffnessMedium))
|
||||
pop.animateTo(0f, spring(dampingRatio = 0.35f, stiffness = Spring.StiffnessMediumLow))
|
||||
}
|
||||
scope.launch {
|
||||
wobble.animateTo(0f, keyframes {
|
||||
durationMillis = 700
|
||||
-20f at 90
|
||||
16f at 220
|
||||
-10f at 360
|
||||
5f at 500
|
||||
0f at 700
|
||||
})
|
||||
}
|
||||
}
|
||||
val morphing = maxOf(hold, pop.value)
|
||||
val morph = remember(shape, pressed) { pressed?.let { Morph(shape, it) } }
|
||||
val plain = shape.toShape()
|
||||
val outline = if (morph != null) null else plain
|
||||
// On a phone on its side the picture and its air shrink, so the words still fit.
|
||||
val short = shortScreen()
|
||||
Column(
|
||||
Modifier.fillMaxWidth().padding(vertical = 48.dp, horizontal = 24.dp),
|
||||
Modifier.fillMaxWidth().padding(vertical = if (short) 12.dp else 48.dp, horizontal = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.size(112.dp)
|
||||
.background(MaterialTheme.colorScheme.primaryContainer, MaterialShapes.Cookie7Sided.toShape()),
|
||||
.size(if (short) 72.dp else 112.dp)
|
||||
.then(
|
||||
if (pressed == null) Modifier
|
||||
else Modifier.clickable(press, indication = null, role = Role.Button, onClickLabel = tapLabel) {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.Confirm)
|
||||
popNow()
|
||||
onTap?.invoke()
|
||||
},
|
||||
),
|
||||
) {
|
||||
Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.onPrimaryContainer, modifier = Modifier.size(48.dp))
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.graphicsLayer {
|
||||
rotationZ = angle
|
||||
val s = 1f + 0.06f * morphing
|
||||
scaleX = s
|
||||
scaleY = s
|
||||
}
|
||||
.background(fill, outline ?: MorphShape(morph!!, morphing)),
|
||||
)
|
||||
Icon(icon, contentDescription = tapLabel, tint = ink, modifier = Modifier.size(if (short) 32.dp else 48.dp).graphicsLayer { rotationZ = -12f * morphing + wobble.value })
|
||||
}
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Spacer(Modifier.height(if (short) 12.dp else 20.dp))
|
||||
Text(title, style = MaterialTheme.typography.titleLargeEmphasized, textAlign = TextAlign.Center)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
@@ -138,3 +250,31 @@ fun Empty(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Two shapes of MaterialShapes mid-way, [progress] 0..1; the shapes fill the unit square. */
|
||||
private class MorphShape(private val morph: Morph, private val progress: Float) : Shape {
|
||||
override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density): Outline {
|
||||
val path = morph.toPath(progress)
|
||||
path.transform(Matrix().apply { scale(size.width, size.height) })
|
||||
return Outline.Generic(path)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An empty day as [restOf] tells it, with its turning shape, which morphs under the finger. A
|
||||
* holiday's party popper throws confetti over the app.
|
||||
*/
|
||||
@Composable
|
||||
fun RestDay(rest: Rest) {
|
||||
val confetti = LocalConfetti.current
|
||||
var center by remember { mutableStateOf(Offset.Zero) }
|
||||
val reduced = rememberReducedMotion()
|
||||
Box(Modifier.onGloballyPositioned { center = it.boundsInWindow().let { b -> Offset(b.center.x, b.top + b.height * 0.25f) } }) {
|
||||
Empty(
|
||||
rest.title, rest.text, icon = rest.icon, shape = rest.shape, tone = rest.tone, spin = true,
|
||||
pressed = rest.pressed,
|
||||
tapLabel = if (rest.confetti) "Хлопушка" else null,
|
||||
onTap = if (rest.confetti && !reduced) ({ confetti?.burst(center) }) else null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
|
||||
package org.xtimms.psu.schedule.ui.components
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.BeachAccess
|
||||
import androidx.compose.material.icons.rounded.Celebration
|
||||
import androidx.compose.material.icons.rounded.Coffee
|
||||
import androidx.compose.material.icons.rounded.LightMode
|
||||
import androidx.compose.material.icons.rounded.School
|
||||
import androidx.compose.material.icons.rounded.Weekend
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.MaterialShapes
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.graphics.shapes.RoundedPolygon
|
||||
import org.xtimms.psu.schedule.data.Semester
|
||||
import org.xtimms.psu.schedule.data.longLabel
|
||||
import org.xtimms.psu.schedule.data.parseDate
|
||||
import org.xtimms.psu.schedule.data.plural
|
||||
import org.xtimms.psu.schedule.data.weekdayName
|
||||
import java.time.LocalDate
|
||||
import java.time.MonthDay
|
||||
import java.time.temporal.ChronoUnit
|
||||
|
||||
// A day without lessons says why: a Sunday, a free Saturday, a public holiday, the days before
|
||||
// the semester, the session and holidays after it, or just a free weekday. Each has its own picture.
|
||||
|
||||
/**
|
||||
* How an empty day looks: its words ([short] without the date, for a line under a title that
|
||||
* already has it), icon, the expressive shape behind it, the shape it morphs into under the
|
||||
* finger and its colour role. A holiday's party popper throws [confetti].
|
||||
*/
|
||||
data class Rest(
|
||||
val title: String,
|
||||
val text: String,
|
||||
val icon: ImageVector,
|
||||
val shape: RoundedPolygon,
|
||||
val tone: Tone,
|
||||
val short: String = text,
|
||||
val pressed: RoundedPolygon = MaterialShapes.Cookie12Sided,
|
||||
val confetti: Boolean = false,
|
||||
) {
|
||||
enum class Tone { Primary, Secondary, Tertiary }
|
||||
}
|
||||
|
||||
/** Belarus' public days off that do not move. */
|
||||
private val HOLIDAYS = mapOf(
|
||||
MonthDay.of(1, 1) to "Новый год",
|
||||
MonthDay.of(1, 2) to "Новый год",
|
||||
MonthDay.of(1, 7) to "Рождество Христово (православное)",
|
||||
MonthDay.of(3, 8) to "День женщин",
|
||||
MonthDay.of(5, 1) to "Праздник труда",
|
||||
MonthDay.of(5, 9) to "День Победы",
|
||||
MonthDay.of(7, 3) to "День Независимости",
|
||||
MonthDay.of(11, 7) to "День Октябрьской революции",
|
||||
MonthDay.of(12, 25) to "Рождество Христово (католическое)",
|
||||
)
|
||||
|
||||
/** Orthodox Easter (Gregorian date) by the Julian computus; right from 1900 to 2099. */
|
||||
private fun orthodoxEaster(year: Int): LocalDate {
|
||||
val a = year % 4
|
||||
val b = year % 7
|
||||
val c = year % 19
|
||||
val d = (19 * c + 15) % 30
|
||||
val e = (2 * a + 4 * b - d + 34) % 7
|
||||
val month = (d + e + 114) / 31
|
||||
val day = (d + e + 114) % 31 + 1
|
||||
return LocalDate.of(year, month, day).plusDays(13)
|
||||
}
|
||||
|
||||
/** The public holiday on [date], if it is one: Радуница moves with Easter. */
|
||||
fun holidayOn(date: LocalDate): String? =
|
||||
HOLIDAYS[MonthDay.from(date)] ?: if (date == orthodoxEaster(date.year).plusDays(9)) "Радуница" else null
|
||||
|
||||
/** Why [date] has no lessons, as far as the calendar tells. */
|
||||
fun restOf(date: LocalDate, semester: Semester?): Rest {
|
||||
val start = semester?.start?.let(::parseDate)
|
||||
val end = semester?.end?.let(::parseDate)
|
||||
val day = "${date.weekdayName()}, ${date.longLabel()}"
|
||||
holidayOn(date)?.let {
|
||||
return Rest("Праздник", "$day — $it. Пар нет, отдыхайте.", Icons.Rounded.Celebration, MaterialShapes.Sunny, Rest.Tone.Tertiary, "$it. Пар нет, отдыхайте.", MaterialShapes.VerySunny, confetti = true)
|
||||
}
|
||||
if (start != null && date < start) {
|
||||
val n = ChronoUnit.DAYS.between(date, start).toInt()
|
||||
return Rest(
|
||||
"До семестра $n ${plural(n, "день", "дня", "дней")}",
|
||||
"${semester.label} начнётся ${start.longLabel()}. Пока можно отдыхать.",
|
||||
Icons.Rounded.School,
|
||||
MaterialShapes.Clover4Leaf,
|
||||
Rest.Tone.Secondary,
|
||||
"Семестр начнётся ${start.longLabel()}.",
|
||||
MaterialShapes.Clover8Leaf,
|
||||
)
|
||||
}
|
||||
// After the last week the session comes, then the holidays: no lessons either way.
|
||||
if (end != null && date > end) return Rest(
|
||||
"Семестр закончился",
|
||||
"${semester.label} закончился ${end.longLabel()}. Дальше — сессия и каникулы.",
|
||||
Icons.Rounded.BeachAccess,
|
||||
MaterialShapes.Sunny,
|
||||
Rest.Tone.Tertiary,
|
||||
"Дальше — сессия и каникулы.",
|
||||
MaterialShapes.VerySunny,
|
||||
)
|
||||
return when (date.dayOfWeek.value) {
|
||||
7 -> Rest("Воскресенье", "${date.longLabel()} — выходной. Набирайтесь сил на неделю.", Icons.Rounded.Weekend, MaterialShapes.Cookie7Sided, Rest.Tone.Primary, "Выходной — силы на неделю.", MaterialShapes.Cookie12Sided)
|
||||
6 -> Rest("Свободная суббота", "${date.longLabel()} — пар нет. Редкая удача.", Icons.Rounded.LightMode, MaterialShapes.Flower, Rest.Tone.Secondary, "Пар нет. Редкая удача.", MaterialShapes.Clover8Leaf)
|
||||
else -> Rest(
|
||||
"Свободный день",
|
||||
"$day — по таблице недель пар нет. Можно выспаться.",
|
||||
Icons.Rounded.Coffee,
|
||||
MaterialShapes.Cookie9Sided,
|
||||
Rest.Tone.Primary,
|
||||
"По таблице недель пар нет. Можно выспаться.",
|
||||
MaterialShapes.Puffy,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.ArrowForward
|
||||
import androidx.compose.material.icons.rounded.Close
|
||||
import androidx.compose.material.icons.rounded.Difference
|
||||
import androidx.compose.material3.Button
|
||||
@@ -25,23 +24,27 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.xtimms.psu.schedule.data.ChangedLesson
|
||||
import org.xtimms.psu.schedule.data.LessonChange
|
||||
import org.xtimms.psu.schedule.data.MINSK
|
||||
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.data.plural
|
||||
import org.xtimms.psu.schedule.data.shortWeekday
|
||||
import org.xtimms.psu.schedule.data.withPlace
|
||||
import org.xtimms.psu.schedule.ui.theme.card
|
||||
import java.time.Instant
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import org.xtimms.psu.schedule.ui.components.Heading
|
||||
import org.xtimms.psu.schedule.data.Timetable
|
||||
import org.xtimms.psu.schedule.data.sentences
|
||||
import org.xtimms.psu.schedule.data.addedSentence
|
||||
import org.xtimms.psu.schedule.data.removedSentence
|
||||
import org.xtimms.psu.schedule.data.dayOf
|
||||
import org.xtimms.psu.schedule.ui.lesson.SubjectDot
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.height
|
||||
import java.time.LocalDate
|
||||
|
||||
/** "3 изменения". */
|
||||
fun changesLabel(n: Int) = "$n ${plural(n, "изменение", "изменения", "изменений")}"
|
||||
@@ -86,9 +89,14 @@ fun ChangesCard(record: ScheduleChanges, onShow: () -> Unit, onClose: () -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
/** The list behind the card: changed, added and removed lessons, as in the PWA. */
|
||||
/**
|
||||
* The list behind the card: changed, added and removed lessons, each said in a sentence
|
||||
* ("Бизнес-анализ в четверг перенесли из 430 в 326"). With [timetable] and [onShow] each has
|
||||
* "В расписании", which opens the day it is on next.
|
||||
*/
|
||||
@Composable
|
||||
fun ChangesSheet(record: ScheduleChanges) {
|
||||
fun ChangesSheet(record: ScheduleChanges, timetable: Timetable? = null, onShow: ((LocalDate) -> Unit)? = null) {
|
||||
val show = { row: ChangedLesson -> if (onShow != null) timetable?.dayOf(row)?.let { day -> { onShow(day) } } else null }
|
||||
Column(
|
||||
Modifier.verticalScroll(rememberScrollState()).padding(start = 24.dp, end = 24.dp, bottom = 32.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
@@ -103,17 +111,15 @@ fun ChangesSheet(record: ScheduleChanges) {
|
||||
)
|
||||
if (record.changed.isNotEmpty()) {
|
||||
Heading("Изменено · ${record.changed.size}", modifier = Modifier.padding(top = 8.dp))
|
||||
record.changed.forEach { ChangedRow(it) }
|
||||
record.changed.forEach { ChangeItem(it.after, it.sentences(), show(it.after)) }
|
||||
}
|
||||
if (record.added.isNotEmpty()) {
|
||||
Heading("Добавлено · ${record.added.size}", modifier = Modifier.padding(top = 8.dp))
|
||||
record.added.forEach { row ->
|
||||
ChangeItem(row, details = listOf(row.lesson.type, row.lesson.teachers.joinToString(", "), withPlace(row.lesson.rooms.joinToString(", ") { "ауд. $it" }, row.lesson.place).orEmpty(), row.lesson.condition))
|
||||
}
|
||||
record.added.forEach { ChangeItem(it, listOf(it.addedSentence()), show(it), details = listOf(it.lesson.type, it.lesson.teachers.joinToString(", "))) }
|
||||
}
|
||||
if (record.removed.isNotEmpty()) {
|
||||
Heading("Удалено · ${record.removed.size}", modifier = Modifier.padding(top = 8.dp))
|
||||
record.removed.forEach { row -> ChangeItem(row, removed = true, details = listOf(row.lesson.type, row.lesson.condition)) }
|
||||
Heading("Убрано · ${record.removed.size}", modifier = Modifier.padding(top = 8.dp))
|
||||
record.removed.forEach { ChangeItem(it, listOf(it.removedSentence()), show(it), removed = true, details = listOf(it.lesson.type)) }
|
||||
}
|
||||
Text(
|
||||
"Сверяйтесь с документом Word: при сомнениях он главнее.",
|
||||
@@ -124,63 +130,38 @@ fun ChangesSheet(record: ScheduleChanges) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun whenText(row: ChangedLesson) =
|
||||
(row.date?.let { parseDate(it).let { d -> "${d.shortWeekday()}, ${d.longLabel()}" } } ?: dayName(row.day)) + " · " + row.lesson.start
|
||||
|
||||
/** A change as its sentences, in the subject's colour, with the lesson's kind and teacher under it. */
|
||||
@Composable
|
||||
private fun ChangedRow(change: LessonChange) {
|
||||
Surface(shape = MaterialTheme.shapes.large, color = MaterialTheme.colorScheme.card, modifier = Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(whenText(change.after), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
|
||||
Text(change.after.lesson.subject, style = MaterialTheme.typography.titleMediumEmphasized)
|
||||
change.fields.forEach { f ->
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("${f.label}: ", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(
|
||||
f.from,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(textDecoration = TextDecoration.LineThrough),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
Icon(
|
||||
Icons.AutoMirrored.Rounded.ArrowForward,
|
||||
contentDescription = "на",
|
||||
modifier = Modifier.padding(horizontal = 6.dp).size(16.dp),
|
||||
)
|
||||
Text(f.to, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f, fill = false))
|
||||
}
|
||||
}
|
||||
if (change.fields.isEmpty()) Text(
|
||||
"Изменился текст ячейки Word",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChangeItem(row: ChangedLesson, details: List<String>, removed: Boolean = false) {
|
||||
private fun ChangeItem(
|
||||
row: ChangedLesson,
|
||||
sentences: List<String>,
|
||||
onShow: (() -> Unit)?,
|
||||
details: List<String> = emptyList(),
|
||||
removed: Boolean = false,
|
||||
) {
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = if (removed) MaterialTheme.colorScheme.surfaceContainer else MaterialTheme.colorScheme.card,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
whenText(row),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = if (removed) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
row.lesson.subject,
|
||||
style = MaterialTheme.typography.titleMediumEmphasized.let {
|
||||
if (removed) it.copy(textDecoration = TextDecoration.LineThrough) else it
|
||||
},
|
||||
color = if (removed) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
details.filter { it.isNotBlank() }.joinToString(" · ").takeIf { it.isNotEmpty() }?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Row(Modifier.padding(start = 16.dp, top = 16.dp, end = 8.dp, bottom = 8.dp)) {
|
||||
SubjectDot(row.lesson.subject, 10.dp, Modifier.padding(top = 7.dp))
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
sentences.forEach {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = if (removed) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
details.filter { it.isNotBlank() }.joinToString(" · ").takeIf { it.isNotEmpty() }?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
if (onShow != null) TextButton(onClick = onShow, contentPadding = PaddingValues(horizontal = 0.dp)) {
|
||||
Text("В расписании")
|
||||
}
|
||||
if (onShow == null) Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package org.xtimms.psu.schedule.ui.home
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.snap
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.xtimms.psu.schedule.data.plural
|
||||
import org.xtimms.psu.schedule.ui.lesson.Slot
|
||||
import org.xtimms.psu.schedule.ui.lesson.subjectColor
|
||||
import org.xtimms.psu.schedule.ui.motion.EmphasizedDecelerate
|
||||
import org.xtimms.psu.schedule.ui.motion.rememberReducedMotion
|
||||
import java.time.LocalTime
|
||||
|
||||
// Today at a glance: the day's lessons as segments of one line from the first start to the last
|
||||
// end, in their subjects' colours, with a dot that walks along it with the clock.
|
||||
|
||||
private fun LocalTime.minutes() = hour * 60 + minute
|
||||
|
||||
/** The day's line; past lessons fade, the dot stands at the current time while the day lasts. */
|
||||
@Composable
|
||||
fun DayLine(slots: List<Slot>, clock: LocalTime, modifier: Modifier = Modifier) {
|
||||
if (slots.isEmpty()) return
|
||||
val start = slots.first().start.minutes()
|
||||
val end = slots.maxOf { it.end }.minutes()
|
||||
val span = (end - start).coerceAtLeast(1).toFloat()
|
||||
val now = clock.minutes()
|
||||
val inDay = now in start..end
|
||||
val reduced = rememberReducedMotion()
|
||||
// The dot slides to the new minute instead of hopping.
|
||||
val at by animateFloatAsState(
|
||||
((now - start) / span).coerceIn(0f, 1f),
|
||||
if (reduced) snap() else tween(1000, easing = EmphasizedDecelerate),
|
||||
label = "now",
|
||||
)
|
||||
val colors = slots.map { subjectColor(it.lessons.first().subject).container }
|
||||
val track = MaterialTheme.colorScheme.outlineVariant
|
||||
val dot = MaterialTheme.colorScheme.primary
|
||||
val ring = MaterialTheme.colorScheme.surfaceContainerLow
|
||||
val running = slots.indexOfFirst { now >= it.start.minutes() && now < it.end.minutes() }
|
||||
val description = "Сегодня ${slots.size} ${plural(slots.size, "пара", "пары", "пар")} с ${slots.first().start} до ${slots.maxOf { it.end }}" +
|
||||
when {
|
||||
running >= 0 -> ", сейчас идёт ${running + 1}-я"
|
||||
now < start -> ""
|
||||
now > end -> ", все прошли"
|
||||
else -> ", сейчас перерыв"
|
||||
}
|
||||
Column(modifier.fillMaxWidth().clearAndSetSemantics { contentDescription = description }) {
|
||||
Canvas(Modifier.fillMaxWidth().height(18.dp)) {
|
||||
val bar = 8.dp.toPx()
|
||||
val y = size.height / 2
|
||||
val radius = CornerRadius(bar / 2)
|
||||
// The gaps: a hairline joining the segments.
|
||||
drawLine(track, Offset(0f, y), Offset(size.width, y), strokeWidth = 2.dp.toPx())
|
||||
slots.forEachIndexed { i, s ->
|
||||
val x0 = (s.start.minutes() - start) / span * size.width
|
||||
val x1 = (s.end.minutes() - start) / span * size.width
|
||||
val past = s.end.minutes() <= now
|
||||
drawRoundRect(
|
||||
colors[i].copy(alpha = if (past) 0.4f else 1f),
|
||||
topLeft = Offset(x0, y - bar / 2),
|
||||
size = Size((x1 - x0).coerceAtLeast(bar), bar),
|
||||
cornerRadius = radius,
|
||||
)
|
||||
}
|
||||
if (inDay) {
|
||||
val x = at * size.width
|
||||
drawCircle(ring, radius = 9.dp.toPx(), center = Offset(x, y))
|
||||
drawCircle(dot, radius = 6.dp.toPx(), center = Offset(x, y))
|
||||
}
|
||||
}
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
val style = MaterialTheme.typography.labelSmall
|
||||
val ink = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
Text(slots.first().start.toString(), style = style, color = ink)
|
||||
if (inDay) Text("сейчас ${clock.withSecond(0).withNano(0)}", style = style, color = dot)
|
||||
Text(slots.maxOf { it.end }.toString(), style = style, color = ink)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -139,6 +139,8 @@ 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
|
||||
import org.xtimms.psu.schedule.ui.components.restOf
|
||||
import org.xtimms.psu.schedule.data.Semester
|
||||
|
||||
@Composable
|
||||
fun HomeScreen(vm: MainViewModel, nav: AppNav) {
|
||||
@@ -246,7 +248,7 @@ fun HomeScreen(vm: MainViewModel, nav: AppNav) {
|
||||
item(key = "week") { AnimatedItem { Entrance(0, intro) { WeekReport(vm, t, subgroup, date, clock) } } }
|
||||
item(key = "session") { AnimatedItem { Entrance(1, intro) { SessionCard(vm, t, onOpen = { session = true }) } } }
|
||||
item(key = "today-title") { AnimatedItem { Entrance(2, intro) { DayTitle("Сегодня", date) } } }
|
||||
item(key = "today") { AnimatedItem { Entrance(3, intro) { TodayCard(todaySlots, vm.placesOn(t, date, subgroup), clock, onOpen = { openLesson(it, date) }, onDay = { nav.openDay(date) }) } } }
|
||||
item(key = "today") { AnimatedItem { Entrance(3, intro) { TodayCard(todaySlots, vm.placesOn(t, date, subgroup), clock, t.semester, onOpen = { openLesson(it, date) }, onDay = { nav.openDay(date) }) } } }
|
||||
item(key = "attendance") { AnimatedItem { AttendanceCard(vm, todaySlots) } }
|
||||
item(key = "tasks") { AnimatedItem { TasksCard(vm, onEdit = { editing = it }, onAll = nav.openPlans) } }
|
||||
if (next != null) {
|
||||
@@ -273,7 +275,9 @@ fun HomeScreen(vm: MainViewModel, nav: AppNav) {
|
||||
}
|
||||
}
|
||||
if (changesOpen) changes?.let { record ->
|
||||
ModalBottomSheet(onDismissRequest = { changesOpen = false }) { ChangesSheet(record) }
|
||||
ModalBottomSheet(onDismissRequest = { changesOpen = false }) {
|
||||
ChangesSheet(record, timetable) { day -> changesOpen = false; nav.openDay(day) }
|
||||
}
|
||||
}
|
||||
if (session) timetable?.let { t ->
|
||||
val exams = upcomingExams(vm, t)
|
||||
@@ -462,11 +466,13 @@ private fun DayTitle(label: String, date: LocalDate) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TodayCard(slots: List<Slot>, places: List<DayPlace>, clock: LocalTime, onOpen: (Lesson) -> Unit, onDay: () -> Unit) {
|
||||
private fun TodayCard(slots: List<Slot>, places: List<DayPlace>, clock: LocalTime, semester: Semester?, onOpen: (Lesson) -> Unit, onDay: () -> Unit) {
|
||||
val left = slots.filter { it.end.isAfter(clock) }
|
||||
SectionCard(Icons.Rounded.Schedule, "Следующие занятия") {
|
||||
// The whole day as one line, with where the clock is on it.
|
||||
DayLine(slots, clock, Modifier.padding(bottom = 4.dp))
|
||||
when {
|
||||
slots.isEmpty() -> Quiet("Сегодня занятий нет", "Отдохните — ближайшие пары ниже.")
|
||||
slots.isEmpty() -> restOf(today(), semester).let { Quiet(it.title, it.short + " Ближайшие пары ниже.") }
|
||||
left.isEmpty() -> Quiet(
|
||||
"Пары на сегодня закончились",
|
||||
"Сегодня было ${slots.size} ${plural(slots.size, "пара", "пары", "пар")}.",
|
||||
@@ -634,20 +640,13 @@ private fun WeekPane(vm: MainViewModel, t: Timetable, subgroup: String?, onOpen:
|
||||
Icon(Icons.AutoMirrored.Rounded.KeyboardArrowRight, contentDescription = "Следующая неделя")
|
||||
}
|
||||
}
|
||||
// The next week slides in from the right, the previous one from the left.
|
||||
val slide = rememberSlideDistance()
|
||||
AnimatedContent(
|
||||
targetState = monday,
|
||||
transitionSpec = { materialSharedAxisX(forward = targetState > initialState, distance = slide) },
|
||||
label = "week",
|
||||
) { shown ->
|
||||
WeekGrid(
|
||||
vm, t, subgroup, shown,
|
||||
onOpen = onOpen,
|
||||
onWeek = { step -> shown.plusDays(7L * step).takeIf { it in loaded }?.let { monday = it } },
|
||||
headerColor = Color.Transparent,
|
||||
modifier = Modifier.fillMaxSize().padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
// The grid changes its week in place: lessons both weeks have stay, the others come and go.
|
||||
WeekGrid(
|
||||
vm, t, subgroup, monday,
|
||||
onOpen = onOpen,
|
||||
onWeek = { step -> monday.plusDays(7L * step).takeIf { it in loaded }?.let { monday = it } },
|
||||
headerColor = Color.Transparent,
|
||||
modifier = Modifier.fillMaxSize().padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,8 @@ 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
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
|
||||
// A long press on a lesson: what one does with it most, without opening its page first.
|
||||
|
||||
@@ -131,7 +133,8 @@ private fun LessonActionsSheet(vm: MainViewModel, lesson: Lesson, date: LocalDat
|
||||
}
|
||||
|
||||
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheet) {
|
||||
Column(Modifier.navigationBarsPadding().padding(bottom = 16.dp)) {
|
||||
// Scrolls on a phone on its side, where the actions do not fit the height.
|
||||
Column(Modifier.verticalScroll(rememberScrollState()).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)
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
package org.xtimms.psu.schedule.ui.motion
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.rotate
|
||||
import androidx.compose.ui.graphics.drawscope.scale
|
||||
import androidx.compose.ui.graphics.lerp
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.positionInWindow
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import org.xtimms.psu.schedule.ui.theme.harmonizeWith
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.exp
|
||||
import kotlin.math.sin
|
||||
import kotlin.random.Random
|
||||
|
||||
// A burst of paper confetti for a holiday: strips fly out of a point, tumble (their back is a
|
||||
// shade darker), sway and drift down against the air. Drawn over the whole app by
|
||||
// ConfettiOverlay; nothing in it takes a touch.
|
||||
|
||||
internal class Bit(
|
||||
var x: Float,
|
||||
var y: Float,
|
||||
var vx: Float,
|
||||
var vy: Float,
|
||||
val w: Float,
|
||||
val h: Float,
|
||||
val front: Color,
|
||||
val back: Color,
|
||||
var turn: Float,
|
||||
val spin: Float,
|
||||
var flip: Float,
|
||||
val flipSpeed: Float,
|
||||
var sway: Float,
|
||||
val swaySpeed: Float,
|
||||
var life: Float,
|
||||
)
|
||||
|
||||
@Stable
|
||||
class ConfettiHost {
|
||||
internal val bits = ArrayList<Bit>()
|
||||
internal var origin = Offset.Zero
|
||||
internal var running by mutableStateOf(false)
|
||||
internal var colors: List<Color> = emptyList()
|
||||
internal var unit = 1f
|
||||
|
||||
/**
|
||||
* Bursts from [at] (a point in the window) up and to both sides. [unit] is the screen's
|
||||
* density, so the paper and its speed look the same on every phone.
|
||||
*/
|
||||
fun burst(at: Offset, count: Int = 110) {
|
||||
if (colors.isEmpty()) return
|
||||
val p = at - origin
|
||||
repeat(count) {
|
||||
// Up and out, a fan of about 140°.
|
||||
val angle = (-90f + Random.nextFloat() * 140f - 70f) * (PI / 180).toFloat()
|
||||
val speed = (380f + Random.nextFloat() * 820f) * unit
|
||||
val color = colors.random()
|
||||
bits += Bit(
|
||||
x = p.x, y = p.y,
|
||||
vx = cos(angle) * speed, vy = sin(angle) * speed,
|
||||
w = (6f + Random.nextFloat() * 5f) * unit,
|
||||
h = (10f + Random.nextFloat() * 8f) * unit,
|
||||
front = color,
|
||||
back = lerp(color, Color.Black, 0.25f),
|
||||
turn = Random.nextFloat() * 360f,
|
||||
spin = (Random.nextFloat() - 0.5f) * 720f,
|
||||
flip = Random.nextFloat() * 2 * PI.toFloat(),
|
||||
flipSpeed = 4f + Random.nextFloat() * 8f,
|
||||
sway = Random.nextFloat() * 2 * PI.toFloat(),
|
||||
swaySpeed = 2f + Random.nextFloat() * 3f,
|
||||
life = 4.5f + Random.nextFloat() * 2f,
|
||||
)
|
||||
}
|
||||
running = true
|
||||
}
|
||||
}
|
||||
|
||||
val LocalConfetti = staticCompositionLocalOf<ConfettiHost?> { null }
|
||||
|
||||
/** Gravity, the air's drag and the speed a strip falls at once the air holds it, per dp. */
|
||||
private const val GRAVITY = 1400f
|
||||
private const val DRAG = 2.2f
|
||||
private const val FALL = 160f
|
||||
|
||||
/** The layer the confetti fly in, over the app; it draws only while some are in the air. */
|
||||
@Composable
|
||||
fun ConfettiOverlay(host: ConfettiHost) {
|
||||
val primary = MaterialTheme.colorScheme.primary
|
||||
// Bright paper, tuned towards the theme so it does not clash with dynamic colours.
|
||||
host.colors = remember(primary) {
|
||||
listOf(0xFFE53935, 0xFFFDD835, 0xFF43A047, 0xFF1E88E5, 0xFF8E24AA, 0xFFFB8C00, 0xFFEC407A)
|
||||
.map { Color(it).harmonizeWith(primary) }
|
||||
}
|
||||
host.unit = LocalDensity.current.density
|
||||
var frame by remember { mutableLongStateOf(0L) }
|
||||
LaunchedEffect(host.running) {
|
||||
if (!host.running) return@LaunchedEffect
|
||||
var last = withFrameNanos { it }
|
||||
while (host.bits.isNotEmpty()) {
|
||||
val now = withFrameNanos { it }
|
||||
val dt = ((now - last) / 1e9f).coerceAtMost(0.05f)
|
||||
last = now
|
||||
val u = host.unit
|
||||
val air = exp(-DRAG * dt)
|
||||
host.bits.removeAll { b ->
|
||||
b.vy += GRAVITY * u * dt
|
||||
b.vx *= air
|
||||
// Past the speed paper falls at, the air slows it down to it.
|
||||
if (b.vy > FALL * u) b.vy = FALL * u + (b.vy - FALL * u) * air
|
||||
b.sway += b.swaySpeed * dt
|
||||
b.x += (b.vx + sin(b.sway) * 40f * u) * dt
|
||||
b.y += b.vy * dt
|
||||
b.turn += b.spin * dt
|
||||
b.flip += b.flipSpeed * dt
|
||||
b.life -= dt
|
||||
b.life <= 0f
|
||||
}
|
||||
frame = now
|
||||
}
|
||||
host.running = false
|
||||
}
|
||||
if (!host.running) return
|
||||
Canvas(Modifier.fillMaxSize().onGloballyPositioned { host.origin = it.positionInWindow() }) {
|
||||
frame
|
||||
for (b in host.bits) {
|
||||
// Tumbling: the strip narrows to its edge and turns over to its darker back.
|
||||
val f = cos(b.flip)
|
||||
val fade = (b.life / 0.8f).coerceIn(0f, 1f)
|
||||
rotate(b.turn, Offset(b.x, b.y)) {
|
||||
scale(1f, f, Offset(b.x, b.y)) {
|
||||
drawRect(
|
||||
(if (f >= 0f) b.front else b.back).copy(alpha = fade),
|
||||
topLeft = Offset(b.x - b.w / 2, b.y - b.h / 2),
|
||||
size = Size(b.w, b.h),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package org.xtimms.psu.schedule.ui.motion
|
||||
|
||||
import androidx.compose.animation.core.MutableTransitionState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.remember
|
||||
|
||||
// Items that stay while their list changes: one still in the list keeps its place (and its
|
||||
// composition), a new one comes in, one that left goes out before it is dropped. The week grid
|
||||
// uses it so that a lesson both weeks have does not move when the week changes.
|
||||
|
||||
/** One item of [rememberPresence]: its [key], its latest value and whether it is shown. */
|
||||
class Presence<T>(val key: Any, val item: T, val visible: MutableTransitionState<Boolean>)
|
||||
|
||||
/**
|
||||
* The items of [items] and those just taken out of it, each with a visibility state for an
|
||||
* `AnimatedVisibility`. Items there from the start are shown at once; later ones animate in.
|
||||
*/
|
||||
@Composable
|
||||
fun <T> rememberPresence(items: List<T>, key: (T) -> Any): List<Presence<T>> {
|
||||
val states = remember { LinkedHashMap<Any, MutableTransitionState<Boolean>>() }
|
||||
val values = remember { HashMap<Any, T>() }
|
||||
val started = remember { booleanArrayOf(false) }
|
||||
val keys = items.map(key)
|
||||
items.forEachIndexed { i, item -> values[keys[i]] = item }
|
||||
for (k in keys) states.getOrPut(k) { MutableTransitionState(!started[0]) }
|
||||
// One that has gone out is dropped; reading its state here recomposes when the exit ends.
|
||||
states.entries.removeAll { (k, s) ->
|
||||
val gone = k !in keys && !s.targetState && !s.currentState && s.isIdle
|
||||
if (gone) values.remove(k)
|
||||
gone
|
||||
}
|
||||
val shown = states.map { (k, s) -> Presence(k, values.getValue(k), s) }
|
||||
// Targets change after the frame is composed, so a new item first composes hidden.
|
||||
SideEffect {
|
||||
started[0] = true
|
||||
for ((k, s) in states) s.targetState = k in keys
|
||||
}
|
||||
return shown
|
||||
}
|
||||
@@ -139,6 +139,12 @@ 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
|
||||
import org.xtimms.psu.schedule.ui.components.RestDay
|
||||
import org.xtimms.psu.schedule.ui.components.restOf
|
||||
import org.xtimms.psu.schedule.data.parseDate
|
||||
import org.xtimms.psu.schedule.ui.components.shortScreen
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
|
||||
private val MONTH = DateTimeFormatter.ofPattern("LLLL yyyy", Locale.forLanguageTag("ru"))
|
||||
private const val MONTHS_BEFORE = 2
|
||||
@@ -309,7 +315,11 @@ fun CalendarScreen(vm: MainViewModel, nav: AppNav) {
|
||||
// Rows stretch to fill the height, as School Planner's month does on a tablet.
|
||||
BoxWithConstraints(m) {
|
||||
val row = ((maxHeight - 40.dp - GRID_BOTTOM - navBarPadding()) / 6).coerceIn(ROW, 112.dp)
|
||||
monthBlock(false, row)
|
||||
// A phone on its side has no room for six weeks: the month scrolls.
|
||||
if (shortScreen()) Column(Modifier.verticalScroll(rememberScrollState())) {
|
||||
monthBlock(false, ROW)
|
||||
Spacer(Modifier.height(navBarPadding()))
|
||||
} else monthBlock(false, row)
|
||||
}
|
||||
}) {
|
||||
DetailPane(it) { agenda(Modifier.fillMaxSize().padding(top = 12.dp)) }
|
||||
@@ -519,11 +529,10 @@ private fun DayAgendaContent(
|
||||
ContentColumn {
|
||||
// Centred in the sheet or the tablet's pane; scrolls rather than squeezing the illustration.
|
||||
CenteredEmpty(bottomInset = 80.dp + navBarPadding()) {
|
||||
if (known) Empty(
|
||||
"Свободный день",
|
||||
"${date.weekdayName()}, ${date.longLabel()}: ни пар, ни планов.",
|
||||
icon = Icons.Rounded.Weekend,
|
||||
) else Empty(
|
||||
// Inside the semester, and before or after it: the day says why it is free.
|
||||
val outside = t?.semester?.let { date < parseDate(it.start) || date > parseDate(it.end) } == true
|
||||
if (known || outside) restOf(date, t?.semester).let { RestDay(it.copy(text = it.text + " Планов тоже нет.")) }
|
||||
else Empty(
|
||||
"Расписание не загружено",
|
||||
"Эта дата вне текущего семестра. Планов на ${date.longLabel()} нет.",
|
||||
icon = Icons.Rounded.EventBusy,
|
||||
|
||||
@@ -128,6 +128,9 @@ 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
|
||||
import org.xtimms.psu.schedule.ui.search.WeekFill
|
||||
import org.xtimms.psu.schedule.ui.search.weekFill
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
|
||||
/** "Сегодня • Суббота • 17 октября", "Завтра • …" or "Понедельник • 19 октября". */
|
||||
private fun dayTitle(d: LocalDate): String = when (d) {
|
||||
@@ -238,8 +241,10 @@ fun PlansScreen(vm: MainViewModel, nav: AppNav) {
|
||||
},
|
||||
)
|
||||
}
|
||||
// The phone's header follows the week strip, fading between weeks under the finger.
|
||||
val fill = remember { WeekFill() }
|
||||
val strip: @Composable (Boolean) -> Unit = { tinted ->
|
||||
SearchWeekStrip(timetable, date, tinted = tinted) { d ->
|
||||
SearchWeekStrip(timetable, date, if (tinted) fill else null) { d ->
|
||||
date = d
|
||||
val index = agendaRows.indexOfFirst { it is Row.Header && it.date == d }
|
||||
if (index >= 0) scope.launch { agendaList.scrollToItem(index) }
|
||||
@@ -261,13 +266,11 @@ fun PlansScreen(vm: MainViewModel, nav: AppNav) {
|
||||
val wide = twoPane(maxWidth)
|
||||
// On a phone the agenda's header wears the week's colour; the change fades.
|
||||
val tinted = !wide && agenda
|
||||
val headerColor by animateColorAsState(
|
||||
if (tinted) weekTint(week) else MaterialTheme.colorScheme.page,
|
||||
MaterialTheme.motionScheme.defaultEffectsSpec(),
|
||||
label = "plans-header",
|
||||
)
|
||||
Column(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.page)) {
|
||||
Column(Modifier.background(headerColor)) {
|
||||
// Switching to the list or the board fades the week's colour out with the strip.
|
||||
val tintedness by animateFloatAsState(if (tinted) 1f else 0f, MaterialTheme.motionScheme.defaultEffectsSpec(), label = "plans-header")
|
||||
val page = MaterialTheme.colorScheme.page
|
||||
Column(Modifier.fillMaxSize().background(page)) {
|
||||
Column(Modifier.weekFill(fill, page) { tintedness }) {
|
||||
appBar()
|
||||
// The phone's week strip folds away for the list and the board.
|
||||
if (!wide) AnimatedVisibility(
|
||||
@@ -542,7 +545,8 @@ private fun Board(
|
||||
@Composable
|
||||
private fun PlansSettings(prefs: PlansPrefs, onChange: (PlansPrefs) -> Unit) {
|
||||
Column(
|
||||
Modifier.padding(start = 24.dp, end = 24.dp, bottom = 32.dp),
|
||||
// Scrolls on a phone on its side, which has less height than the sheet's options.
|
||||
Modifier.verticalScroll(rememberScrollState()).padding(start = 24.dp, end = 24.dp, bottom = 32.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Heading("Вид планов", style = MaterialTheme.typography.headlineSmallEmphasized)
|
||||
|
||||
@@ -35,6 +35,7 @@ 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.layout.heightIn
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
@@ -139,6 +140,13 @@ import org.xtimms.psu.schedule.ui.lesson.LocalDisplay
|
||||
import org.xtimms.psu.schedule.ui.components.TimeText
|
||||
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import org.xtimms.psu.schedule.ui.components.RestDay
|
||||
import org.xtimms.psu.schedule.ui.components.restOf
|
||||
import org.xtimms.psu.schedule.data.Semester
|
||||
import androidx.compose.ui.graphics.lerp
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import org.xtimms.psu.schedule.ui.components.CollapsingTop
|
||||
import org.xtimms.psu.schedule.ui.components.shortScreen
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
@@ -237,7 +245,8 @@ private fun Planner(
|
||||
}
|
||||
fun week(step: Int) {
|
||||
if (weekView) {
|
||||
val next = grid.plusDays(7L * step)
|
||||
// The state, not [grid]: a swipe handler may hold this function from an older frame.
|
||||
val next = LocalDate.ofEpochDay(gridMonday).plusDays(7L * step)
|
||||
if (dates.contains(next)) gridMonday = next.toEpochDay()
|
||||
} else {
|
||||
val next = date.plusDays(7L * step)
|
||||
@@ -291,6 +300,18 @@ private fun Planner(
|
||||
val shownMonday = if (weekView) grid else date.monday()
|
||||
val week = weekOf(shownMonday)
|
||||
val tint = weekTint(week)
|
||||
// The day view's header follows the day strip under the finger: between two weeks it is a
|
||||
// fade of their colours, read at drawing time so a swipe does not recompose the screen.
|
||||
val tints = mondays.map { weekTintOf(weekOf(it)) }
|
||||
val headerFill: () -> Color = {
|
||||
if (weekView || tints.isEmpty()) tint
|
||||
else {
|
||||
val page = strip.currentPage.coerceIn(0, tints.lastIndex)
|
||||
val offset = strip.currentPageOffsetFraction
|
||||
val other = (page + if (offset > 0) 1 else -1).coerceIn(0, tints.lastIndex)
|
||||
lerp(tints[page], tints[other], kotlin.math.abs(offset).coerceAtMost(1f))
|
||||
}
|
||||
}
|
||||
val onToday = if (weekView) grid == today().monday() else date == today()
|
||||
val doc = t.document
|
||||
|
||||
@@ -309,6 +330,9 @@ private fun Planner(
|
||||
// Tablets (not on the table): a lesson opens in a pane beside the day or the week, M3's
|
||||
// list-detail, instead of a page over them. Another group's timetable closes it.
|
||||
val paneMode = wide && fold == null
|
||||
// A phone on its side: the header slides away while the day or the week scrolls.
|
||||
val short = shortScreen()
|
||||
val pageColor = MaterialTheme.colorScheme.page
|
||||
var chosen by remember(t.group, t.document.id) { mutableStateOf<Pair<Lesson, LocalDate>?>(null) }
|
||||
val open: (Lesson, LocalDate) -> Unit = if (paneMode) { lesson, day -> chosen = lesson to day } else onOpen
|
||||
// "Где посидеть": the window's day, its pairs and the next lesson's building.
|
||||
@@ -318,7 +342,7 @@ private fun Planner(
|
||||
val paneOut = MaterialTheme.motionScheme.fastEffectsSpec<Float>()
|
||||
val clock = rememberClock()
|
||||
val header: @Composable () -> Unit = {
|
||||
Column(Modifier.background(if (wide) MaterialTheme.colorScheme.page else tint)) {
|
||||
Column(if (wide) Modifier.background(MaterialTheme.colorScheme.page) else Modifier.drawBehind { drawRect(headerFill()) }) {
|
||||
TopAppBar(
|
||||
// Below the fold of a phone on the table the bar has no status bar above it.
|
||||
windowInsets = if (fold != null) WindowInsets(0) else TopAppBarDefaults.windowInsets,
|
||||
@@ -375,7 +399,8 @@ private fun Planner(
|
||||
exit = shrinkVertically(MaterialTheme.motionScheme.defaultSpatialSpec()) + fadeOut(MaterialTheme.motionScheme.fastEffectsSpec()),
|
||||
) { HorizontalPager(state = strip, beyondViewportPageCount = 1) { page ->
|
||||
val monday = mondays[page]
|
||||
Box(Modifier.fillMaxWidth().background(if (wide) Color.Transparent else weekTintOf(weekOf(monday))), contentAlignment = Alignment.Center) {
|
||||
// The header behind the strip carries the colour, fading as the strip pages.
|
||||
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
DayStrip(
|
||||
monday = monday,
|
||||
selected = date,
|
||||
@@ -410,26 +435,16 @@ private fun Planner(
|
||||
label = "schedule-view",
|
||||
) { showWeek ->
|
||||
if (showWeek) {
|
||||
// The week grid pages through weeks under the finger as well.
|
||||
val weeks = rememberPagerState(initialPage = mondays.indexOf(grid).coerceAtLeast(0)) { mondays.size }
|
||||
LaunchedEffect(gridMonday) {
|
||||
val index = mondays.indexOf(grid)
|
||||
if (index >= 0 && index != weeks.currentPage) weeks.animateScrollToPage(index)
|
||||
}
|
||||
LaunchedEffect(weeks) {
|
||||
snapshotFlow { weeks.settledPage }.collect { gridMonday = mondays[it].toEpochDay() }
|
||||
}
|
||||
HorizontalPager(state = weeks, verticalAlignment = Alignment.Top) { page ->
|
||||
val monday = mondays[page]
|
||||
WeekGrid(
|
||||
vm, t, subgroup, monday,
|
||||
onOpen = open,
|
||||
onWeek = ::week,
|
||||
headerColor = if (wide) Color.Transparent else weekTintOf(weekOf(monday)),
|
||||
// Room under the card's rounded top, as School Planner leaves above its days.
|
||||
modifier = if (wide) Modifier.padding(top = 16.dp) else Modifier,
|
||||
)
|
||||
}
|
||||
// One grid that changes its week in place: a lesson both weeks have stays where it
|
||||
// is, the others come and go. A swipe on it goes to the next or previous week.
|
||||
WeekGrid(
|
||||
vm, t, subgroup, grid,
|
||||
onOpen = open,
|
||||
onWeek = ::week,
|
||||
headerColor = if (wide) Color.Transparent else weekTint(weekOf(grid)),
|
||||
// Room under the card's rounded top, as School Planner leaves above its days.
|
||||
modifier = if (wide) Modifier.padding(top = 16.dp) else Modifier,
|
||||
)
|
||||
} else ExpressiveRefresh(refreshing = status.loading, onRefresh = { vm.refresh() }) {
|
||||
val fresh by vm.fresh.collectAsStateWithLifecycle()
|
||||
HorizontalPager(state = pager, modifier = Modifier.fillMaxSize()) { page ->
|
||||
@@ -446,6 +461,7 @@ private fun Planner(
|
||||
.firstNotNullOfOrNull { d -> vm.slotsOn(t, d, subgroup).firstOrNull() }
|
||||
},
|
||||
onGo = ::go,
|
||||
semester = t.semester,
|
||||
onWindow = { from, to, building ->
|
||||
val pairs = t.bells.filter { !parseTime(it.start).isBefore(from) && !parseTime(it.end).isAfter(to) }
|
||||
if (pairs.isNotEmpty()) freeRooms = Triple(pageDate, pairs, building)
|
||||
@@ -475,11 +491,12 @@ private fun Planner(
|
||||
clock = clock,
|
||||
onOpen = { onOpen(it, pageDate) },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
semester = t.semester,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(fold.height.toDp()))
|
||||
// The week's colour fills the whole lower half, not only the bar.
|
||||
Column(Modifier.fillMaxSize().background(if (wide) MaterialTheme.colorScheme.page else tint)) {
|
||||
Column(Modifier.fillMaxSize().then(if (wide) Modifier.background(MaterialTheme.colorScheme.page) else Modifier.drawBehind { drawRect(headerFill()) })) {
|
||||
header()
|
||||
if (!weekView) StandLessons(
|
||||
date = date,
|
||||
@@ -491,8 +508,7 @@ private fun Planner(
|
||||
modifier = Modifier.padding(top = 8.dp, bottom = 16.dp),
|
||||
)
|
||||
}
|
||||
} else if (paneMode) {
|
||||
header()
|
||||
} else if (paneMode) CollapsingTop(short, barColor = { pageColor }, header = header) {
|
||||
CompositionLocalProvider(LocalChosenLesson provides chosen?.let { (l, d) -> chosenKey(l, d) }) {
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
timetable(Modifier.weight(1f).fillMaxHeight())
|
||||
@@ -515,10 +531,7 @@ private fun Planner(
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
header()
|
||||
timetable(Modifier.fillMaxSize())
|
||||
}
|
||||
} else CollapsingTop(short, barColor = { if (wide) pageColor else headerFill() }, header = header) { timetable(Modifier.fillMaxSize()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -569,6 +582,7 @@ private fun DayPage(
|
||||
onGo: (LocalDate) -> Unit = {},
|
||||
/** "Где посидеть" in a window: free rooms from one time to another, near a building. */
|
||||
onWindow: ((LocalTime, LocalTime, String?) -> Unit)? = null,
|
||||
semester: Semester? = null,
|
||||
) {
|
||||
val clock = rememberClock()
|
||||
val isToday = date == today()
|
||||
@@ -585,6 +599,10 @@ private fun DayPage(
|
||||
list.scrollToItem(before)
|
||||
}
|
||||
ContentColumn {
|
||||
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||
// An empty day is centred in the page, but grows past it rather than squeezing its
|
||||
// card on a short screen (Pixel 1, landscape): then the page scrolls.
|
||||
val page = maxHeight - 40.dp - navBarPadding()
|
||||
LazyColumn(
|
||||
state = list,
|
||||
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 24.dp + navBarPadding()),
|
||||
@@ -592,9 +610,10 @@ private fun DayPage(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
if (slots.isEmpty()) item {
|
||||
Box(Modifier.fillParentMaxHeight(0.85f).fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
Box(Modifier.fillMaxWidth().heightIn(min = page), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(24.dp)) {
|
||||
Empty("Занятий нет", "По таблице недель в этот день свободно — можно отдохнуть.", icon = Icons.Rounded.Weekend)
|
||||
// Sunday, a holiday, the holidays after the semester: each says so in its own way.
|
||||
RestDay(restOf(date, semester))
|
||||
// Instead of only "nothing today": when the next lesson is, and a tap to its day.
|
||||
following?.let { NextLessonCard(it, onGo) }
|
||||
}
|
||||
@@ -640,6 +659,7 @@ private fun DayPage(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -62,6 +62,8 @@ 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
|
||||
import org.xtimms.psu.schedule.ui.components.restOf
|
||||
import org.xtimms.psu.schedule.data.Semester
|
||||
|
||||
// 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,
|
||||
@@ -85,6 +87,7 @@ fun StandDay(
|
||||
clock: LocalTime,
|
||||
onOpen: (Lesson) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
semester: Semester? = null,
|
||||
) {
|
||||
val isToday = date == today()
|
||||
val slot = slots.firstOrNull { it.start == chosen } ?: standSlot(slots, date, clock)
|
||||
@@ -112,7 +115,7 @@ fun StandDay(
|
||||
modifier = Modifier.padding(bottom = 12.dp),
|
||||
)
|
||||
when {
|
||||
slots.isEmpty() -> StandQuiet("Пар нет", "В этот день занятий нет — выберите другой день внизу.")
|
||||
slots.isEmpty() -> restOf(date, semester).let { StandQuiet(it.title, it.short + " Другой день — в ленте внизу.", it.icon) }
|
||||
slot == null -> StandQuiet(
|
||||
"Пары на сегодня закончились",
|
||||
"Сегодня было ${slots.size} ${plural(slots.size, "пара", "пары", "пар")}. Завтрашний день — в ленте внизу.",
|
||||
@@ -216,13 +219,13 @@ private fun StandMeta(icon: ImageVector, text: String) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StandQuiet(title: String, text: String) {
|
||||
private fun StandQuiet(title: String, text: String, icon: androidx.compose.ui.graphics.vector.ImageVector = Icons.Rounded.EventAvailable) {
|
||||
Column(
|
||||
Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Icon(Icons.Rounded.EventAvailable, contentDescription = null, modifier = Modifier.size(40.dp), tint = MaterialTheme.colorScheme.primary)
|
||||
Icon(icon, contentDescription = null, modifier = Modifier.size(40.dp), tint = MaterialTheme.colorScheme.primary)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(title, style = MaterialTheme.typography.titleLargeEmphasized)
|
||||
Text(text, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
|
||||
@@ -78,6 +78,36 @@ import androidx.compose.foundation.text.TextAutoSize
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.xtimms.psu.schedule.ui.lesson.LocalChosenLesson
|
||||
import org.xtimms.psu.schedule.ui.lesson.chosenKey
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.EnterTransition
|
||||
import androidx.compose.animation.ExitTransition
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.animate
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.snap
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import kotlinx.coroutines.launch
|
||||
import org.xtimms.psu.schedule.ui.motion.Emphasized
|
||||
import org.xtimms.psu.schedule.ui.motion.EmphasizedAccelerate
|
||||
import org.xtimms.psu.schedule.ui.motion.EmphasizedDecelerate
|
||||
import org.xtimms.psu.schedule.ui.motion.RollingText
|
||||
import org.xtimms.psu.schedule.ui.motion.rememberPresence
|
||||
import org.xtimms.psu.schedule.ui.motion.rememberReducedMotion
|
||||
|
||||
/** An hour at 100 % text; it grows with the text, up to [GRID_TEXT]. */
|
||||
private val HOUR_BASE = 60.dp
|
||||
@@ -108,6 +138,16 @@ fun WeekGrid(
|
||||
}
|
||||
}
|
||||
|
||||
/** A lesson of the grid, where it stands in its day column: which of the slot's lessons, of how many. */
|
||||
private class Block(val date: LocalDate, val slot: Slot, val lesson: Lesson, val index: Int, val of: Int)
|
||||
|
||||
/**
|
||||
* What makes a lesson "the same" from one week to the next: weekday, time, subject and kind.
|
||||
* Its room may differ (it then changes in place); anything else is another lesson.
|
||||
*/
|
||||
private fun Block.key(): String =
|
||||
"${date.dayOfWeek.value}|${slot.start}|${lesson.subject.lowercase()}|${lesson.type.lowercase()}|${lesson.subgroup.orEmpty()}|$index"
|
||||
|
||||
@Composable
|
||||
private fun WeekGridBody(
|
||||
vm: MainViewModel,
|
||||
@@ -121,6 +161,7 @@ private fun WeekGridBody(
|
||||
) {
|
||||
val HOUR = HOUR_BASE * LocalDensity.current.fontScale.coerceAtLeast(1f)
|
||||
val clock = rememberClock()
|
||||
val reduced = rememberReducedMotion()
|
||||
val all = (0..6L).map { monday.plusDays(it) }.map { it to vm.slotsOn(t, it, subgroup) }
|
||||
// Sunday only takes a column when something happens on it.
|
||||
val days = all.filter { (d, slots) -> d.dayOfWeek.value < 7 || slots.isNotEmpty() }
|
||||
@@ -129,12 +170,37 @@ private fun WeekGridBody(
|
||||
val last = maxOf(18, slots.maxOfOrNull { it.end.hour + if (it.end.minute > 0) 1 else 0 } ?: 18)
|
||||
val hours = last - first
|
||||
fun top(time: LocalTime) = HOUR * ((time.hour - first) * 60 + time.minute) / 60f
|
||||
// Kept lessons glide when the hours of the grid change; new ones grow in, old ones fade out.
|
||||
val move = if (reduced) snap() else tween<Dp>(350, easing = Emphasized)
|
||||
val enter = if (reduced) EnterTransition.None else fadeIn(tween(250, delayMillis = 80)) + scaleIn(tween(300, delayMillis = 80, easing = EmphasizedDecelerate), initialScale = 0.85f)
|
||||
val exit = if (reduced) ExitTransition.None else fadeOut(tween(150)) + scaleOut(tween(200, easing = EmphasizedAccelerate), targetScale = 0.9f)
|
||||
|
||||
// A sideways swipe turns the week: the grid follows the finger a little, then settles back.
|
||||
val scope = rememberCoroutineScope()
|
||||
var drag by remember { mutableFloatStateOf(0f) }
|
||||
val turn = with(LocalDensity.current) { 72.dp.toPx() }
|
||||
val week by rememberUpdatedState(onWeek)
|
||||
val follow = Modifier.graphicsLayer { translationX = drag * 0.35f; alpha = 1f - (kotlin.math.abs(drag) / (turn * 6)).coerceAtMost(0.25f) }
|
||||
|
||||
val line = MaterialTheme.colorScheme.outlineVariant
|
||||
Column(modifier) {
|
||||
Column(
|
||||
modifier.pointerInput(Unit) {
|
||||
detectHorizontalDragGestures(
|
||||
onDragEnd = {
|
||||
if (kotlin.math.abs(drag) > turn) week(if (drag < 0) 1 else -1)
|
||||
scope.launch { animate(drag, 0f, animationSpec = spring(stiffness = Spring.StiffnessMediumLow)) { v, _ -> drag = v } }
|
||||
},
|
||||
onDragCancel = { scope.launch { animate(drag, 0f) { v, _ -> drag = v } } },
|
||||
) { change, dx ->
|
||||
change.consume()
|
||||
drag += dx
|
||||
}
|
||||
},
|
||||
) {
|
||||
Row(
|
||||
Modifier
|
||||
.background(headerColor)
|
||||
.then(follow)
|
||||
.padding(start = GUTTER, bottom = 8.dp)
|
||||
.semantics {
|
||||
customActions = listOf(
|
||||
@@ -144,35 +210,31 @@ private fun WeekGridBody(
|
||||
},
|
||||
) {
|
||||
days.forEach { (d, _) ->
|
||||
val isToday = d == today()
|
||||
val places = vm.placesOn(t, d, subgroup)
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.semantics(mergeDescendants = true) {
|
||||
contentDescription = "${d.weekdayName()}, ${d.longLabel()}"
|
||||
},
|
||||
) {
|
||||
Text(
|
||||
d.shortWeekday(),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = if (isToday) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
key(d.dayOfWeek) {
|
||||
val isToday = d == today()
|
||||
val places = vm.placesOn(t, d, subgroup)
|
||||
val circle by animateColorAsState(if (isToday) MaterialTheme.colorScheme.primary else Color.Transparent, label = "today")
|
||||
val ink by animateColorAsState(if (isToday) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface, label = "today-ink")
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.background(if (isToday) MaterialTheme.colorScheme.primary else Color.Transparent, CircleShape),
|
||||
.weight(1f)
|
||||
.semantics(mergeDescendants = true) {
|
||||
contentDescription = "${d.weekdayName()}, ${d.longLabel()}"
|
||||
},
|
||||
) {
|
||||
Text(
|
||||
"${d.dayOfMonth}",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
color = if (isToday) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface,
|
||||
d.shortWeekday(),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = if (isToday) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(36.dp).background(circle, CircleShape)) {
|
||||
// The dates roll to the new week's, as on a desk calendar.
|
||||
RollingText("${d.dayOfMonth}", style = MaterialTheme.typography.titleMediumEmphasized.copy(color = ink))
|
||||
}
|
||||
// "Занятия в Полоцке": the town of the whole day, under its date.
|
||||
DayPlaceMark(places, Modifier.padding(top = 2.dp, start = 2.dp, end = 2.dp))
|
||||
}
|
||||
// "Занятия в Полоцке": the town of the whole day, under its date.
|
||||
DayPlaceMark(places, Modifier.padding(top = 2.dp, start = 2.dp, end = 2.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -193,38 +255,46 @@ private fun WeekGridBody(
|
||||
)
|
||||
}
|
||||
days.forEach { (d, daySlots) ->
|
||||
BoxWithConstraints(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.drawBehind {
|
||||
val step = size.height / hours
|
||||
for (h in 0..hours) drawLine(line, Offset(0f, step * h), Offset(size.width, step * h), 1f)
|
||||
drawLine(line, Offset(0f, 0f), Offset(0f, size.height), 1f)
|
||||
},
|
||||
) {
|
||||
val wide = maxWidth >= 96.dp
|
||||
daySlots.forEach { slot ->
|
||||
val y = top(slot.start)
|
||||
val height = top(slot.end) - y
|
||||
val width = maxWidth / slot.lessons.size
|
||||
slot.lessons.forEachIndexed { i, lesson ->
|
||||
LessonBlock(
|
||||
lesson, slot, wide,
|
||||
notes = LocalNotes.current.forLesson(lesson.subject, d, lesson.start).open().size,
|
||||
onClick = { onOpen(lesson, d) },
|
||||
modifier = Modifier
|
||||
.offset(x = width * i, y = y)
|
||||
.size(width, height)
|
||||
.padding(horizontal = 2.dp, vertical = 1.dp),
|
||||
)
|
||||
key(d.dayOfWeek) {
|
||||
BoxWithConstraints(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.drawBehind {
|
||||
val step = size.height / hours
|
||||
for (h in 0..hours) drawLine(line, Offset(0f, step * h), Offset(size.width, step * h), 1f)
|
||||
drawLine(line, Offset(0f, 0f), Offset(0f, size.height), 1f)
|
||||
}
|
||||
.then(follow),
|
||||
) {
|
||||
val wide = maxWidth >= 96.dp
|
||||
val column = maxWidth
|
||||
val blocks = daySlots.flatMap { slot -> slot.lessons.mapIndexed { i, l -> Block(d, slot, l, i, slot.lessons.size) } }
|
||||
rememberPresence(blocks) { it.key() }.forEach { p ->
|
||||
key(p.key) {
|
||||
val b = p.item
|
||||
val y by animateDpAsState(top(b.slot.start), move, label = "y")
|
||||
val height by animateDpAsState(top(b.slot.end) - top(b.slot.start), move, label = "h")
|
||||
val width by animateDpAsState(column / b.of, move, label = "w")
|
||||
val x by animateDpAsState(column / b.of * b.index, move, label = "x")
|
||||
Box(Modifier.offset(x = x, y = y).size(width, height).padding(horizontal = 2.dp, vertical = 1.dp)) {
|
||||
androidx.compose.animation.AnimatedVisibility(visibleState = p.visible, enter = enter, exit = exit) {
|
||||
LessonBlock(
|
||||
b.lesson, b.slot, wide,
|
||||
notes = LocalNotes.current.forLesson(b.lesson.subject, b.date, b.lesson.start).open().size,
|
||||
onClick = { onOpen(b.lesson, b.date) },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (d == today() && clock.hour in first until last) {
|
||||
// The "now" line slides down with the clock instead of hopping every half minute.
|
||||
val y by animateDpAsState(top(clock), tween(1000), label = "now")
|
||||
Box(Modifier.offset(y = y - 1.dp).fillMaxWidth().height(2.dp).background(MaterialTheme.colorScheme.primary))
|
||||
Box(Modifier.offset(x = (-5).dp, y = y - 5.dp).size(10.dp).background(MaterialTheme.colorScheme.primary, CircleShape))
|
||||
}
|
||||
}
|
||||
if (d == today() && clock.hour in first until last) {
|
||||
// The "now" line slides down with the clock instead of hopping every half minute.
|
||||
val y by androidx.compose.animation.core.animateDpAsState(top(clock), tween(1000), label = "now")
|
||||
Box(Modifier.offset(y = y - 1.dp).fillMaxWidth().height(2.dp).background(MaterialTheme.colorScheme.primary))
|
||||
Box(Modifier.offset(x = (-5).dp, y = y - 5.dp).size(10.dp).background(MaterialTheme.colorScheme.primary, CircleShape))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -280,7 +350,10 @@ private fun LessonBlock(lesson: Lesson, slot: Slot, wide: Boolean, notes: Int, o
|
||||
softWrap = false,
|
||||
autoSize = TextAutoSize.StepBased(minFontSize = 9.sp, maxFontSize = style.fontSize),
|
||||
)
|
||||
if (room.isNotEmpty()) Text(room, style = MaterialTheme.typography.labelSmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
// Another week's room for the same lesson cross-fades in place.
|
||||
androidx.compose.animation.Crossfade(room, label = "room") { r ->
|
||||
if (r.isNotEmpty()) Text(r, style = MaterialTheme.typography.labelSmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
} }
|
||||
}
|
||||
|
||||
@@ -113,6 +113,9 @@ 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
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.graphics.lerp
|
||||
|
||||
private sealed interface Target {
|
||||
data class Teacher(val name: String) : Target
|
||||
@@ -203,7 +206,8 @@ fun SearchScreen(vm: MainViewModel, nav: AppNav) {
|
||||
MaterialTheme.motionScheme.defaultEffectsSpec(),
|
||||
label = "search-header",
|
||||
)
|
||||
val weekColor = weekTint(week)
|
||||
// The dated header follows its week strip, fading between weeks under the finger.
|
||||
val fill = remember { WeekFill() }
|
||||
|
||||
val appBar: @Composable () -> Unit = {
|
||||
TopAppBar(
|
||||
@@ -436,16 +440,16 @@ fun SearchScreen(vm: MainViewModel, nav: AppNav) {
|
||||
Icon(Icons.Rounded.Close, contentDescription = "Закрыть")
|
||||
}
|
||||
}
|
||||
SearchWeekStrip(timetable, date, tinted = false) { date = it }
|
||||
SearchWeekStrip(timetable, date) { date = it }
|
||||
}
|
||||
dayList(Modifier.fillMaxSize())
|
||||
}
|
||||
}
|
||||
}
|
||||
} else Column(Modifier.fillMaxSize().nestedScroll(scroll.nestedScrollConnection)) {
|
||||
Column(Modifier.background(if (dated) weekColor else scrolledTint)) {
|
||||
Column(if (dated) Modifier.weekFill(fill, scrolledTint) else Modifier.background(scrolledTint)) {
|
||||
appBar()
|
||||
if (dated) SearchWeekStrip(timetable, date) { date = it }
|
||||
if (dated) SearchWeekStrip(timetable, date, fill) { date = it }
|
||||
}
|
||||
Box(Modifier.fillMaxSize().background(if (target != null) MaterialTheme.colorScheme.surface else MaterialTheme.colorScheme.page)) {
|
||||
ContentColumn {
|
||||
@@ -465,12 +469,30 @@ fun SearchScreen(vm: MainViewModel, nav: AppNav) {
|
||||
}
|
||||
}
|
||||
|
||||
/** The calm day strip as a pager of weeks around today; each page wears its week's colour. */
|
||||
/**
|
||||
* The colour of a header above a [SearchWeekStrip]: between two weeks while the strip pages,
|
||||
* a fade of their colours. Read at drawing time ([color]), so a swipe repaints without
|
||||
* recomposing the screen.
|
||||
*/
|
||||
@Stable
|
||||
class WeekFill {
|
||||
var color: () -> Color = { Color.Transparent }
|
||||
internal set
|
||||
}
|
||||
|
||||
/** A header background that follows [fill], blended [amount] of the way from [base]. */
|
||||
fun Modifier.weekFill(fill: WeekFill, base: Color, amount: () -> Float = { 1f }): Modifier =
|
||||
drawBehind { drawRect(lerp(base, fill.color(), amount())) }
|
||||
|
||||
/**
|
||||
* The calm day strip as a pager of weeks around today. With [fill] the header above it wears
|
||||
* the week's colour and fades between weeks as the strip pages; the strip itself has none.
|
||||
*/
|
||||
@Composable
|
||||
internal fun SearchWeekStrip(
|
||||
timetable: org.xtimms.psu.schedule.data.Timetable?,
|
||||
date: LocalDate,
|
||||
tinted: Boolean = true,
|
||||
fill: WeekFill? = null,
|
||||
onDate: (LocalDate) -> Unit,
|
||||
) {
|
||||
val base = remember { startDate().monday() }
|
||||
@@ -494,14 +516,21 @@ internal fun SearchWeekStrip(
|
||||
if (monday != current.monday()) onDate(monday.plusDays(current.dayOfWeek.value - 1L))
|
||||
}
|
||||
}
|
||||
if (fill != null) {
|
||||
// Only two colours exist, a white week's and a green one's.
|
||||
val white = weekTintOf(null)
|
||||
val green = weekTintOf(org.xtimms.psu.schedule.data.Week(0, "green"))
|
||||
val weeks = remember(timetable) { timetable?.days.orEmpty().associate { it.date to it.week } }
|
||||
fun tintAt(page: Int): Color = if (weeks[mondayAt(page).toString()]?.white == false) green else white
|
||||
fill.color = {
|
||||
val offset = pager.currentPageOffsetFraction
|
||||
val page = pager.currentPage
|
||||
lerp(tintAt(page), tintAt(page + if (offset > 0) 1 else -1), kotlin.math.abs(offset).coerceAtMost(1f))
|
||||
}
|
||||
}
|
||||
HorizontalPager(state = pager, beyondViewportPageCount = 1) { page ->
|
||||
val monday = mondayAt(page)
|
||||
Box(
|
||||
Modifier.fillMaxWidth().background(
|
||||
if (tinted) weekTintOf(timetable?.days?.firstOrNull { it.date == monday.toString() }?.week) else Color.Transparent,
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
DayStrip(
|
||||
monday = monday,
|
||||
selected = date,
|
||||
|
||||
Reference in New Issue
Block a user