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:
2026-10-26 19:56:10 +03:00
parent 5c80b3f53c
commit f7f4e2b230
20 changed files with 169392 additions and 159014 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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())
}
}
}

View File

@@ -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,
)
}
}

View File

@@ -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,
)
}
}

View File

@@ -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))
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -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),
)
}
}

View File

@@ -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)

View File

@@ -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),
)
}
}
}
}
}

View File

@@ -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
}

View File

@@ -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,

View File

@@ -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)

View File

@@ -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(
}
}
}
}
}
}

View File

@@ -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)

View File

@@ -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)
}
}
} }
}

View File

@@ -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,