feat: Implement week grid layout for timetable display

- Added WeekGrid composable to visualize weekly timetable with lessons.
- Introduced LessonBlock for individual lesson representation.
- Created theme file for consistent color schemes across the app.
- Developed NextLessonWidget and TodayWidget for home screen integration.
- Implemented Upcoming data class to manage upcoming lessons.
- Added WidgetStyle for dynamic theming in widgets.
- Created SyncWorker for periodic timetable updates and widget refresh.
This commit is contained in:
2026-10-18 16:55:12 +03:00
parent d900e038b6
commit 3dc6cc4e9e
41 changed files with 208520 additions and 249602 deletions

View File

@@ -98,8 +98,9 @@ DOMAIN=schedule.example.by docker compose --profile https up -d --build
## Android-приложение ## Android-приложение
В папке `android/` — нативный клиент на Kotlin и Jetpack Compose с компонентами Material 3 Expressive (`MaterialExpressiveTheme`, `ModalWideNavigationRail` и `WideNavigationRail`, соединённые `ToggleButton`, `LoadingIndicator`). Базовая тема синяя (тональная схема M3); с «Цветами обоев» — динамическая, а цвета предметов и зелёной недели гармонизируются с ней. Разделы открываются из бокового меню (на планшете и в альбомной ориентации — постоянная боковая панель); карточки лежат на подложке с оттенком, системные панели следуют теме приложения. В папке `android/` — нативный клиент на Kotlin и Jetpack Compose с компонентами Material 3 Expressive (`MaterialExpressiveTheme`, `ModalWideNavigationRail` и `WideNavigationRail`, соединённые `ToggleButton`, `LoadingIndicator`). Базовая тема синяя (тональная схема M3); с «Цветами обоев» — динамическая, а цвета предметов и зелёной недели гармонизируются с ней. Разделы открываются из бокового меню: на телефоне оно выезжает, на планшете в портрете и на телефоне в альбомной ориентации — узкая боковая панель, на планшете в альбомной ориентации меню открыто постоянно. На широком экране разделы делятся на две панели, как в School Planner: «Обзор» — карточки и недельная сетка, «Планы» и «Календарь» — список и выбранный день, «Поиск» — результаты и день преподавателя или аудитории; «Предметы» — сеткой карточек, «Расписание» открывается неделей; карточки лежат на подложке с оттенком, системные панели следуют теме приложения.
- Первый запуск — короткий онбординг: приветствие с тремя возможностями приложения, затем шаги с волнистым индикатором прогресса: группа, подгруппа (только если группа делится на подгруппы) и напоминания. Разрешение на уведомления спрашивается на шаге напоминаний, его можно отложить; «Назад» (с предиктивным жестом) возвращает на предыдущий шаг. После онбординга открывается «Обзор» выбранной группы.
- Обзор: отчёт за неделю (сколько пар прошло), следующие занятия сегодня с обратным отсчётом и ближайший учебный день. - Обзор: отчёт за неделю (сколько пар прошло), следующие занятия сегодня с обратным отсчётом и ближайший учебный день.
- Расписание группы по дням с листанием или недельной сеткой по часам; неделя и её цвет, номера пар, текущая пара, детали занятия с исходной записью Word. Если семестр не найден в таблице недель — строки Word по дням недели. - Расписание группы по дням с листанием или недельной сеткой по часам; неделя и её цвет, номера пар, текущая пара, детали занятия с исходной записью Word. Если семестр не найден в таблице недель — строки Word по дням недели.
- Предметы: пары по дням текущей недели; у каждого предмета — сокращение как в недельной сетке, следующее занятие с аудиторией, преподаватели и сколько пар на этой неделе. У каждого предмета свой постоянный цвет во всех разделах. - Предметы: пары по дням текущей недели; у каждого предмета — сокращение как в недельной сетке, следующее занятие с аудиторией, преподаватели и сколько пар на этой неделе. У каждого предмета свой постоянный цвет во всех разделах.

View File

@@ -10,15 +10,15 @@ val apiUrl = providers.gradleProperty("PSU_API_URL")
.map { if (it.endsWith("/")) it else "$it/" } .map { if (it.endsWith("/")) it else "$it/" }
android { android {
namespace = "ritm.schedule" namespace = "org.xtimms.psu.schedule"
compileSdk { compileSdk {
version = release(37) { minorApiLevel = 1 } version = release(37) { minorApiLevel = 1 }
} }
defaultConfig { defaultConfig {
applicationId = "ritm.schedule" applicationId = "org.xtimms.psu.schedule"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 37
versionCode = 1 versionCode = 1
versionName = "0.1.0" versionName = "0.1.0"
buildConfigField("String", "API_URL", "\"${apiUrl.get()}\"") buildConfigField("String", "API_URL", "\"${apiUrl.get()}\"")
@@ -27,6 +27,7 @@ android {
buildTypes { buildTypes {
release { release {
isMinifyEnabled = true isMinifyEnabled = true
isShrinkResources = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
// A release build must talk to a public HTTPS server. // A release build must talk to a public HTTPS server.
require(apiUrl.get().startsWith("https://") || !gradle.startParameter.taskNames.any { "Release" in it }) { require(apiUrl.get().startsWith("https://") || !gradle.startParameter.taskNames.any { "Release" in it }) {

View File

@@ -1,4 +1,4 @@
# kotlinx.serialization keeps generated serializers; models are plain data classes. # kotlinx.serialization keeps generated serializers; models are plain data classes.
-keepattributes *Annotation*, InnerClasses -keepattributes *Annotation*, InnerClasses
-keep,includedescriptorclasses class ritm.schedule.data.**$$serializer { *; } -keep,includedescriptorclasses class org.xtimms.psu.schedule.data.**$$serializer { *; }
-keepclassmembers class ritm.schedule.data.** { *** Companion; } -keepclassmembers class org.xtimms.psu.schedule.data.** { *** Companion; }

File diff suppressed because it is too large Load Diff

View File

@@ -1,18 +1,27 @@
package ritm.schedule package org.xtimms.psu.schedule
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.Orientation
import kotlin.coroutines.cancellation.CancellationException
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.rememberTransition
import androidx.compose.animation.core.SeekableTransitionState
import androidx.activity.compose.PredictiveBackHandler
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.outlined.CalendarViewWeek import androidx.compose.material.icons.outlined.CalendarViewWeek
import androidx.compose.material.icons.outlined.Checklist import androidx.compose.material.icons.outlined.Checklist
import androidx.compose.material.icons.rounded.CalendarViewWeek import androidx.compose.material.icons.rounded.CalendarViewWeek
import androidx.compose.material.icons.rounded.Checklist import androidx.compose.material.icons.rounded.Checklist
import ritm.schedule.ui.CalendarScreen import org.xtimms.psu.schedule.ui.CalendarScreen
import ritm.schedule.ui.PlansScreen import org.xtimms.psu.schedule.ui.PlansScreen
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@@ -23,20 +32,15 @@ import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.material.icons.automirrored.rounded.MenuOpen import androidx.compose.material.icons.automirrored.rounded.MenuOpen
import androidx.compose.material3.ExtendedFloatingActionButton import org.xtimms.psu.schedule.ui.materialFadeThrough
import androidx.compose.material3.FloatingActionButtonDefaults import org.xtimms.psu.schedule.ui.Emphasized
import androidx.compose.ui.semantics.contentDescription import org.xtimms.psu.schedule.ui.MotionDuration
import ritm.schedule.ui.materialFadeThrough
import ritm.schedule.ui.Emphasized
import ritm.schedule.ui.MotionDuration
import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import ritm.schedule.ui.materialSharedAxisX import org.xtimms.psu.schedule.ui.materialSharedAxisX
import ritm.schedule.ui.rememberSlideDistance import org.xtimms.psu.schedule.ui.rememberSlideDistance
import android.content.res.Configuration
import android.graphics.Color import android.graphics.Color
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
@@ -46,10 +50,6 @@ import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -73,13 +73,11 @@ import androidx.compose.material.icons.rounded.Home
import androidx.compose.material.icons.rounded.Menu import androidx.compose.material.icons.rounded.Menu
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material.icons.rounded.School import androidx.compose.material.icons.rounded.School
import androidx.compose.material.icons.rounded.SwapHoriz
import androidx.compose.material.icons.rounded.TravelExplore import androidx.compose.material.icons.rounded.TravelExplore
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalWideNavigationRail
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.WideNavigationRail import androidx.compose.material3.WideNavigationRail
@@ -97,35 +95,32 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import ritm.schedule.data.ThemeMode import org.xtimms.psu.schedule.data.ThemeMode
import ritm.schedule.data.today import org.xtimms.psu.schedule.data.today
import ritm.schedule.ui.AppNav import org.xtimms.psu.schedule.ui.AppNav
import ritm.schedule.ui.GroupsScreen import org.xtimms.psu.schedule.ui.GroupsScreen
import ritm.schedule.ui.HomeScreen import org.xtimms.psu.schedule.ui.HomeScreen
import ritm.schedule.ui.LocalDisplay import org.xtimms.psu.schedule.ui.Onboarding
import ritm.schedule.ui.LocalMarks import org.xtimms.psu.schedule.ui.LocalDisplay
import ritm.schedule.ui.LocalMenuProgress import org.xtimms.psu.schedule.ui.LocalMarks
import ritm.schedule.ui.theme.card import org.xtimms.psu.schedule.ui.LocalMenuProgress
import org.xtimms.psu.schedule.ui.theme.card
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import ritm.schedule.ui.LocalNotes import org.xtimms.psu.schedule.ui.LocalNotes
import ritm.schedule.ui.LocalSubjectSlots import org.xtimms.psu.schedule.ui.LocalSubjectSlots
import ritm.schedule.data.Display import org.xtimms.psu.schedule.data.Display
import ritm.schedule.ui.MainViewModel import org.xtimms.psu.schedule.ui.MainViewModel
import ritm.schedule.ui.ScheduleScreen import org.xtimms.psu.schedule.ui.ScheduleScreen
import ritm.schedule.ui.SearchScreen import org.xtimms.psu.schedule.ui.SearchScreen
import ritm.schedule.ui.SettingsScreen import org.xtimms.psu.schedule.ui.SettingsScreen
import ritm.schedule.ui.SubjectsScreen import org.xtimms.psu.schedule.ui.SubjectsScreen
import ritm.schedule.ui.subjectSlots import org.xtimms.psu.schedule.ui.subjectSlots
import ritm.schedule.ui.theme.ScheduleTheme import org.xtimms.psu.schedule.ui.theme.ScheduleTheme
import ritm.schedule.ui.theme.isDark import org.xtimms.psu.schedule.ui.theme.isDark
import ritm.schedule.ui.theme.page import org.xtimms.psu.schedule.ui.theme.page
import java.time.LocalDate import java.time.LocalDate
private enum class Tab(val label: String, val icon: ImageVector, val selectedIcon: ImageVector) { private enum class Tab(val label: String, val icon: ImageVector, val selectedIcon: ImageVector) {
@@ -164,8 +159,19 @@ class MainActivity : ComponentActivity() {
} }
ScheduleTheme(mode = s?.theme ?: ThemeMode.System, dynamicColor = s?.dynamicColor ?: false) { ScheduleTheme(mode = s?.theme ?: ThemeMode.System, dynamicColor = s?.dynamicColor ?: false) {
Surface(color = MaterialTheme.colorScheme.page, modifier = Modifier.fillMaxSize()) { Surface(color = MaterialTheme.colorScheme.page, modifier = Modifier.fillMaxSize()) {
// Wait for the stored settings so a returning student never sees the first-run screen. // Wait for the stored settings so a returning student never sees the onboarding.
if (s != null) App(vm, firstRun = s.current == null) if (s != null) {
// Decided once: picking the group mid-onboarding must not cut the remaining steps short.
var onboarding by rememberSaveable { mutableStateOf(s.current == null) }
AnimatedContent(
targetState = onboarding,
transitionSpec = { materialFadeThrough() },
label = "onboarding",
) { first ->
if (first) Onboarding(vm, onDone = { onboarding = false })
else App(vm, firstRun = s.current == null)
}
}
} }
} }
} }
@@ -175,24 +181,57 @@ class MainActivity : ComponentActivity() {
/** Window width from which the menu turns into a permanent side rail (M3 medium window class). */ /** Window width from which the menu turns into a permanent side rail (M3 medium window class). */
private val MEDIUM_WIDTH = 600.dp 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
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
private fun App(vm: MainViewModel, firstRun: Boolean) { private fun App(vm: MainViewModel, firstRun: Boolean) {
var tab by rememberSaveable { mutableStateOf(Tab.Home) } var tab by rememberSaveable { mutableStateOf(Tab.Home) }
var page by rememberSaveable { mutableStateOf(if (firstRun) Page.Groups else null) } var page by rememberSaveable { mutableStateOf(if (firstRun) Page.Groups else null) }
var weekView by rememberSaveable { mutableStateOf(false) } // Unset until the student picks: wide windows open the schedule as a week grid.
var weekChoice by rememberSaveable { mutableStateOf<Boolean?>(null) }
var focus by remember { mutableStateOf<LocalDate?>(null) } var focus by remember { mutableStateOf<LocalDate?>(null) }
var menuOpen by rememberSaveable { mutableStateOf(false) } var menuOpen by rememberSaveable { mutableStateOf(false) }
val settings by vm.settings.collectAsStateWithLifecycle() val settings by vm.settings.collectAsStateWithLifecycle()
val timetable by vm.timetable.collectAsStateWithLifecycle() val timetable by vm.timetable.collectAsStateWithLifecycle()
val hasGroup = settings?.current != null val hasGroup = settings?.current != null
val rail = rememberWideNavigationRailState() val rail = rememberWideNavigationRailState()
// Landscape tablets start with the menu open and keep it beside the screen either way.
val deskRail = rememberWideNavigationRailState(WideNavigationRailValue.Expanded)
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
// Back closes a page first, then returns to the overview instead of closing the app. // Back closes a page first, then returns to the overview instead of closing the app.
BackHandler(enabled = page != null && hasGroup) { page = null } // Predictive back: the gesture scrubs the very transition a tap would play, so the
BackHandler(enabled = page == null && tab != Tab.Home) { tab = Tab.Home } // student sees where back leads before letting go; a cancelled swipe springs back.
// Declared last, so an open menu closes before anything else. val pageState = remember { SeekableTransitionState(page) }
BackHandler(enabled = menuOpen) { menuOpen = false } val tabState = remember { SeekableTransitionState(tab) }
var gesture by remember { mutableStateOf(false) }
LaunchedEffect(page) { pageState.animateTo(page) }
LaunchedEffect(tab) { tabState.animateTo(tab) }
PredictiveBackHandler(enabled = page != null && hasGroup && !menuOpen) { events ->
gesture = true
try {
events.collect { pageState.seekTo(it.progress, targetState = null) }
page = null
} catch (e: CancellationException) {
scope.launch { pageState.animateTo(pageState.currentState) }
throw e
} finally {
gesture = false
}
}
PredictiveBackHandler(enabled = page == null && tab != Tab.Home && !menuOpen) { events ->
gesture = true
try {
events.collect { tabState.seekTo(it.progress, targetState = Tab.Home) }
tab = Tab.Home
} catch (e: CancellationException) {
scope.launch { tabState.animateTo(tabState.currentState) }
throw e
} finally {
gesture = false
}
}
val slots = remember(timetable) { subjectSlots(timetable) } val slots = remember(timetable) { subjectSlots(timetable) }
val slide = rememberSlideDistance() val slide = rememberSlideDistance()
@@ -200,13 +239,15 @@ private fun App(vm: MainViewModel, firstRun: Boolean) {
// Behind the pushed-aside screen: the menu's own card-white background. // Behind the pushed-aside screen: the menu's own card-white background.
BoxWithConstraints(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.card)) { BoxWithConstraints(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.card)) {
val wide = maxWidth >= MEDIUM_WIDTH val wide = maxWidth >= MEDIUM_WIDTH
BackHandler(enabled = wide && (rail.targetValue == WideNavigationRailValue.Expanded)) { scope.launch { rail.collapse() } } val permanent = maxWidth >= EXPANDED_WIDTH
val weekView = weekChoice ?: permanent
BackHandler(enabled = wide && !permanent && (rail.targetValue == WideNavigationRailValue.Expanded)) { scope.launch { rail.collapse() } }
val nav = remember(wide) { val nav = remember(wide) {
AppNav( AppNav(
openGroups = { page = Page.Groups }, openGroups = { page = Page.Groups },
openSettings = { page = Page.Settings }, openSettings = { page = Page.Settings },
openDay = { focus = it; weekView = false; tab = Tab.Schedule }, openDay = { focus = it; weekChoice = false; tab = Tab.Schedule },
openWeek = { focus = today(); weekView = true; tab = Tab.Schedule }, openWeek = { focus = today(); weekChoice = true; tab = Tab.Schedule },
openSubjects = { tab = Tab.Subjects }, openSubjects = { tab = Tab.Subjects },
openSearch = { tab = Tab.Search }, openSearch = { tab = Tab.Search },
openPlans = { tab = Tab.Plans; page = null }, openPlans = { tab = Tab.Plans; page = null },
@@ -227,11 +268,25 @@ private fun App(vm: MainViewModel, firstRun: Boolean) {
// The screen begins and ends on screen, so both ways use the M3 emphasized easing // The screen begins and ends on screen, so both ways use the M3 emphasized easing
// (md.sys.motion.easing.emphasized): long2 500 ms to open, medium4 400 ms to close. // (md.sys.motion.easing.emphasized): long2 500 ms to open, medium4 400 ms to close.
val opening = menuOpen && !wide val opening = menuOpen && !wide
val openState = animateFloatAsState( val openAnim = remember { Animatable(0f) }
LaunchedEffect(opening) {
openAnim.animateTo(
if (opening) 1f else 0f, if (opening) 1f else 0f,
tween(if (opening) MotionDuration.Long2 else MotionDuration.Medium4, easing = Emphasized), tween(if (opening) MotionDuration.Long2 else MotionDuration.Medium4, easing = Emphasized),
label = "menu",
) )
}
// Back on an open menu: the screen follows the finger part of the way home, then
// closes on release or slides back open if the swipe is cancelled.
PredictiveBackHandler(enabled = opening) { events ->
try {
events.collect { openAnim.snapTo(1f - it.progress * 0.25f) }
menuOpen = false
} catch (e: CancellationException) {
scope.launch { openAnim.animateTo(1f, tween(MotionDuration.Medium4, easing = Emphasized)) }
throw e
}
}
val openState = openAnim.asState()
// Read only in layout and draw lambdas, so a running animation never recomposes the app. // Read only in layout and draw lambdas, so a running animation never recomposes the app.
val open by openState val open by openState
var menuWidth by remember { mutableIntStateOf(0) } var menuWidth by remember { mutableIntStateOf(0) }
@@ -259,7 +314,26 @@ private fun App(vm: MainViewModel, firstRun: Boolean) {
// The screen keeps one place in the tree whether the rail is shown or not, // The screen keeps one place in the tree whether the rail is shown or not,
// so rotating the phone keeps its saved state (selected day, week view). // so rotating the phone keeps its saved state (selected day, week view).
Row(Modifier.fillMaxSize()) { Row(Modifier.fillMaxSize()) {
if (wide) MenuRail( if (permanent) {
val open = deskRail.targetValue == WideNavigationRailValue.Expanded
// The rail pushes the screen aside rather than covering it, so picking a
// section leaves the menu as it was. Its width eases between the collapsed
// 96dp and 280dp, so the screen beside it narrows along with it.
val railWidth by animateDpAsState(
if (open) 280.dp else 96.dp,
tween(MotionDuration.Medium4, easing = Emphasized),
label = "rail-width",
)
MenuRail(
state = deskRail,
expanded = open,
items = menuItems,
color = MaterialTheme.colorScheme.page,
modifier = Modifier.widthIn(min = railWidth),
onToggle = { scope.launch { deskRail.toggle() } },
onPicked = {},
)
} else if (wide) MenuRail(
state = rail, state = rail,
expanded = (rail.targetValue == WideNavigationRailValue.Expanded), expanded = (rail.targetValue == WideNavigationRailValue.Expanded),
items = menuItems, items = menuItems,
@@ -295,11 +369,9 @@ private fun App(vm: MainViewModel, firstRun: Boolean) {
LocalNotes provides notes, LocalNotes provides notes,
LocalMarks provides marks, LocalMarks provides marks,
) { ) {
AnimatedContent( rememberTransition(pageState, label = "page").AnimatedContent(
targetState = page,
// Shared axis X: forward into a page, back out of it, as in RideBus. // Shared axis X: forward into a page, back out of it, as in RideBus.
transitionSpec = { materialSharedAxisX(forward = targetState != null, distance = slide) }, transitionSpec = { materialSharedAxisX(forward = targetState != null, distance = slide, predictive = gesture) },
label = "page",
) { current -> ) { current ->
// Pages pad for side insets; their lists end above the navigation bar themselves. // Pages pad for side insets; their lists end above the navigation bar themselves.
val pageInsets = Modifier.fillMaxSize() val pageInsets = Modifier.fillMaxSize()
@@ -313,10 +385,8 @@ private fun App(vm: MainViewModel, firstRun: Boolean) {
) )
} }
Page.Settings -> Box(pageInsets) { SettingsScreen(vm, onBack = { page = null }) } Page.Settings -> Box(pageInsets) { SettingsScreen(vm, onBack = { page = null }) }
null -> AnimatedContent( null -> rememberTransition(tabState, label = "tab").AnimatedContent(
targetState = tab,
transitionSpec = { materialFadeThrough() }, transitionSpec = { materialFadeThrough() },
label = "tab",
) { shown -> ) { shown ->
when (shown) { when (shown) {
Tab.Home -> HomeScreen(vm, nav) Tab.Home -> HomeScreen(vm, nav)
@@ -325,7 +395,7 @@ private fun App(vm: MainViewModel, firstRun: Boolean) {
Tab.Schedule -> ScheduleScreen( Tab.Schedule -> ScheduleScreen(
vm, nav, vm, nav,
weekView = weekView, weekView = weekView,
onWeekView = { weekView = it }, onWeekView = { weekChoice = it },
focus = focus, focus = focus,
onFocusHandled = { focus = null }, onFocusHandled = { focus = null },
) )
@@ -336,10 +406,21 @@ private fun App(vm: MainViewModel, firstRun: Boolean) {
} }
} }
} }
// While the menu is open the pushed-aside screen only closes it. // While the menu is open the pushed-aside screen only closes it: by a tap, or by
// dragging it back to the left with the finger.
if (menuOpen && !wide) Box( if (menuOpen && !wide) Box(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.draggable(
state = rememberDraggableState { delta ->
if (menuWidth > 0) scope.launch { openAnim.snapTo((openAnim.value + delta / menuWidth).coerceIn(0f, 1f)) }
},
orientation = Orientation.Horizontal,
onDragStopped = { velocity ->
if (velocity < -600f || openAnim.value < 0.6f) menuOpen = false
else openAnim.animateTo(1f, tween(MotionDuration.Medium4, easing = Emphasized))
},
)
.clickable( .clickable(
interactionSource = null, interactionSource = null,
indication = null, indication = null,
@@ -383,15 +464,16 @@ private fun MenuRail(
// On a landscape phone the items need every bit of height; they scroll when it runs out. // On a landscape phone the items need every bit of height; they scroll when it runs out.
contentPadding = PaddingValues(0.dp), contentPadding = PaddingValues(0.dp),
header = { header = {
IconButton( // A 64dp row like the top app bar's, so the button sits level with the screen
onClick = onToggle, // title; 24dp in, so its icon lines up with the item icons below in both states.
modifier = Modifier.padding(start = if (expanded) 12.dp else 24.dp, bottom = if (expanded) 12.dp else 0.dp), Box(Modifier.height(64.dp).padding(start = 24.dp), contentAlignment = Alignment.CenterStart) {
) { IconButton(onClick = onToggle) {
Icon( Icon(
if (expanded) Icons.AutoMirrored.Rounded.MenuOpen else Icons.Rounded.Menu, if (expanded) Icons.AutoMirrored.Rounded.MenuOpen else Icons.Rounded.Menu,
contentDescription = if (expanded) "Закрыть меню" else "Открыть меню", contentDescription = if (expanded) "Закрыть меню" else "Открыть меню",
) )
} }
}
}, },
) { ) {
Column( Column(
@@ -405,8 +487,9 @@ private fun MenuRail(
WideNavigationRailItem( WideNavigationRailItem(
selected = selected, selected = selected,
onClick = { items.onTab(item); onPicked() }, onClick = { items.onTab(item); onPicked() },
icon = { Icon(if (selected) item.selectedIcon else item.icon, contentDescription = null) }, // Collapsed, the rail shows icons only; the name stays for TalkBack.
label = { Text(item.label) }, icon = { Icon(if (selected) item.selectedIcon else item.icon, contentDescription = if (expanded) null else item.label) },
label = if (expanded) ({ Text(item.label) }) else null,
railExpanded = expanded, railExpanded = expanded,
) )
} }

View File

@@ -1,13 +1,13 @@
package ritm.schedule package org.xtimms.psu.schedule
import android.app.Application import android.app.Application
import ritm.schedule.data.Api import org.xtimms.psu.schedule.data.Api
import ritm.schedule.data.AttendanceStore import org.xtimms.psu.schedule.data.AttendanceStore
import ritm.schedule.data.NotesStore import org.xtimms.psu.schedule.data.NotesStore
import ritm.schedule.data.Repository import org.xtimms.psu.schedule.data.Repository
import ritm.schedule.data.Settings import org.xtimms.psu.schedule.data.Settings
import ritm.schedule.notify.Reminders import org.xtimms.psu.schedule.notify.Reminders
import ritm.schedule.work.SyncWorker import org.xtimms.psu.schedule.work.SyncWorker
class ScheduleApp : Application() { class ScheduleApp : Application() {
lateinit var settings: Settings lateinit var settings: Settings

View File

@@ -1,5 +1,6 @@
package ritm.schedule.data package org.xtimms.psu.schedule.data
import kotlinx.coroutines.delay
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@@ -34,7 +35,7 @@ class Api(baseUrl: String) {
val url = base.newBuilder().addPathSegments(path.trimStart('/')).apply { val url = base.newBuilder().addPathSegments(path.trimStart('/')).apply {
for ((name, value) in query) if (!value.isNullOrEmpty()) addQueryParameter(name, value) for ((name, value) in query) if (!value.isNullOrEmpty()) addQueryParameter(name, value)
}.build() }.build()
val body = try { val body = withRetry(url.toString()) {
client.newCall(Request.Builder().url(url).build()).execute().use { response -> client.newCall(Request.Builder().url(url).build()).execute().use { response ->
val text = response.body?.string().orEmpty() val text = response.body?.string().orEmpty()
if (!response.isSuccessful) { if (!response.isSuccessful) {
@@ -49,12 +50,34 @@ class Api(baseUrl: String) {
} }
text text
} }
} catch (e: IOException) {
throw ApiException("Нет связи с сервером расписания. Показана сохранённая копия.", offline = true)
} }
json.decodeFromString<T>(body) json.decodeFromString<T>(body)
} }
/**
* Runs a GET, retrying a dropped or timed-out connection twice (after 1 and 3 seconds):
* a flaky mobile network often recovers within seconds. Server answers are not retried.
*/
private suspend fun withRetry(url: String, call: () -> String): String {
var wait = 1_000L
repeat(RETRIES) { attempt ->
try {
return call()
} catch (e: IOException) {
android.util.Log.w("Api", "GET $url failed (attempt ${attempt + 1})", e)
if (attempt == RETRIES - 1) throw ApiException(OFFLINE, offline = true)
delay(wait)
wait *= 3
}
}
throw ApiException(OFFLINE, offline = true)
}
private companion object {
const val RETRIES = 3
const val OFFLINE = "Нет связи с сервером расписания."
}
/** The whole current semester; a server without range=semester answers the from/days window. */ /** The whole current semester; a server without range=semester answers the from/days window. */
suspend fun timetable(docId: String, group: String, from: String, days: Int) = suspend fun timetable(docId: String, group: String, from: String, days: Int) =
get<Timetable>( get<Timetable>(

View File

@@ -1,4 +1,4 @@
package ritm.schedule.data package org.xtimms.psu.schedule.data
import android.content.Context import android.content.Context
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers

View File

@@ -1,4 +1,4 @@
package ritm.schedule.data package org.xtimms.psu.schedule.data
import java.time.DayOfWeek import java.time.DayOfWeek
import java.time.LocalDate import java.time.LocalDate

View File

@@ -1,4 +1,4 @@
package ritm.schedule.data package org.xtimms.psu.schedule.data
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable

View File

@@ -1,4 +1,4 @@
package ritm.schedule.data package org.xtimms.psu.schedule.data
import android.content.Context import android.content.Context
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers

View File

@@ -1,4 +1,4 @@
package ritm.schedule.data package org.xtimms.psu.schedule.data
import android.content.Context import android.content.Context
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers

View File

@@ -1,4 +1,4 @@
package ritm.schedule.data package org.xtimms.psu.schedule.data
import android.content.Context import android.content.Context
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences

View File

@@ -1,6 +1,6 @@
package ritm.schedule.notify package org.xtimms.psu.schedule.notify
import ritm.schedule.data.hideKey import org.xtimms.psu.schedule.data.hideKey
import android.Manifest import android.Manifest
import android.app.AlarmManager import android.app.AlarmManager
import android.app.NotificationChannel import android.app.NotificationChannel
@@ -17,14 +17,14 @@ import androidx.core.content.ContextCompat
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import ritm.schedule.MainActivity import org.xtimms.psu.schedule.MainActivity
import ritm.schedule.R import org.xtimms.psu.schedule.R
import ritm.schedule.app import org.xtimms.psu.schedule.app
import ritm.schedule.data.MINSK import org.xtimms.psu.schedule.data.MINSK
import ritm.schedule.data.Timetable import org.xtimms.psu.schedule.data.Timetable
import ritm.schedule.data.UserSettings import org.xtimms.psu.schedule.data.UserSettings
import ritm.schedule.data.parseDate import org.xtimms.psu.schedule.data.parseDate
import ritm.schedule.data.parseTime import org.xtimms.psu.schedule.data.parseTime
import java.time.Instant import java.time.Instant
/** /**
@@ -34,7 +34,7 @@ import java.time.Instant
object Reminders { object Reminders {
private const val CHANNEL = "before_lesson" private const val CHANNEL = "before_lesson"
private const val MAX = 24 private const val MAX = 24
private const val ACTION = "ritm.schedule.REMIND" private const val ACTION = "org.xtimms.psu.schedule.REMIND"
private const val WINDOW = 10 * 60 * 1000L private const val WINDOW = 10 * 60 * 1000L
/** Exact times need the student's consent on Android 12+ ("Будильники и напоминания"). */ /** Exact times need the student's consent on Android 12+ ("Будильники и напоминания"). */

View File

@@ -1,4 +1,4 @@
package ritm.schedule.tile package org.xtimms.psu.schedule.tile
import android.app.PendingIntent import android.app.PendingIntent
import android.content.ComponentName import android.content.ComponentName
@@ -12,8 +12,8 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import ritm.schedule.MainActivity import org.xtimms.psu.schedule.MainActivity
import ritm.schedule.widget.upcoming import org.xtimms.psu.schedule.widget.upcoming
/** /**
* A Quick Settings tile with the lesson in progress or the next one: when, which room and * A Quick Settings tile with the lesson in progress or the next one: when, which room and

View File

@@ -1,6 +1,6 @@
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) @file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
package ritm.schedule.ui package org.xtimms.psu.schedule.ui
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -27,10 +27,10 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import ritm.schedule.data.Lesson import org.xtimms.psu.schedule.data.Lesson
import ritm.schedule.data.Mark import org.xtimms.psu.schedule.data.Mark
import ritm.schedule.data.nowTime import org.xtimms.psu.schedule.data.nowTime
import ritm.schedule.data.today import org.xtimms.psu.schedule.data.today
import java.time.LocalDate import java.time.LocalDate
/** Attendance marks of the open group, for cards deep in the tree. */ /** Attendance marks of the open group, for cards deep in the tree. */

View File

@@ -1,9 +1,10 @@
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
package ritm.schedule.ui package org.xtimms.psu.schedule.ui
import androidx.compose.foundation.verticalScroll import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.rememberScrollState import androidx.compose.ui.unit.Dp
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.Add
@@ -59,8 +60,6 @@ import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.Assignment
import androidx.compose.material.icons.rounded.Schedule
import androidx.compose.material.icons.rounded.Today import androidx.compose.material.icons.rounded.Today
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
@@ -88,15 +87,15 @@ import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import ritm.schedule.data.Lesson import org.xtimms.psu.schedule.data.Lesson
import ritm.schedule.data.Note import org.xtimms.psu.schedule.data.Note
import ritm.schedule.data.Timetable import org.xtimms.psu.schedule.data.Timetable
import ritm.schedule.data.Week import org.xtimms.psu.schedule.data.Week
import ritm.schedule.data.longLabel import org.xtimms.psu.schedule.data.longLabel
import ritm.schedule.data.monday import org.xtimms.psu.schedule.data.monday
import ritm.schedule.data.today import org.xtimms.psu.schedule.data.today
import ritm.schedule.data.weekdayName import org.xtimms.psu.schedule.data.weekdayName
import ritm.schedule.ui.theme.page import org.xtimms.psu.schedule.ui.theme.page
import java.time.LocalDate import java.time.LocalDate
import java.time.YearMonth import java.time.YearMonth
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
@@ -189,9 +188,7 @@ fun CalendarScreen(vm: MainViewModel, nav: AppNav) {
} }
val drag = rememberDraggableState { delta -> collapse = (collapse - delta / range).coerceIn(0f, 1f) } val drag = rememberDraggableState { delta -> collapse = (collapse - delta / range).coerceIn(0f, 1f) }
Box(Modifier.fillMaxSize()) { val appBar: @Composable () -> Unit = {
Column(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.page).nestedScroll(connection)) {
Column {
TopAppBar( TopAppBar(
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent), colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent),
navigationIcon = { MenuButton(nav) }, navigationIcon = { MenuButton(nav) },
@@ -208,6 +205,10 @@ fun CalendarScreen(vm: MainViewModel, nav: AppNav) {
}) { Icon(Icons.Rounded.Today, contentDescription = "Сегодня") } }) { Icon(Icons.Rounded.Today, contentDescription = "Сегодня") }
}, },
) )
}
// The weekday row and the month pager; [fold] lets the day sheet fold it up to one week.
val monthBlock: @Composable (Boolean, Dp) -> Unit = { fold, row ->
Column {
Row(Modifier.widthIn(max = MAX_CONTENT).fillMaxWidth().padding(horizontal = 12.dp).align(Alignment.CenterHorizontally)) { Row(Modifier.widthIn(max = MAX_CONTENT).fillMaxWidth().padding(horizontal = 12.dp).align(Alignment.CenterHorizontally)) {
WEEKDAYS.forEach { WEEKDAYS.forEach {
Text( Text(
@@ -223,8 +224,10 @@ fun CalendarScreen(vm: MainViewModel, nav: AppNav) {
val m = base.plusMonths(page.toLong()) val m = base.plusMonths(page.toLong())
// The chosen week's row stays in view as the grid folds up under the panel. // The chosen week's row stays in view as the grid folds up under the panel.
val keep = if (YearMonth.from(selected) == m) weekRow(m, selected) else 0 val keep = if (YearMonth.from(selected) == m) weekRow(m, selected) else 0
// Centred like the weekday row above it, so the columns line up on wide screens.
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.TopCenter) {
MonthGrid( MonthGrid(
modifier = Modifier modifier = if (!fold) Modifier else Modifier
.clipToBounds() .clipToBounds()
.layout { measurable, constraints -> .layout { measurable, constraints ->
val placeable = measurable.measure(constraints) val placeable = measurable.measure(constraints)
@@ -235,14 +238,44 @@ fun CalendarScreen(vm: MainViewModel, nav: AppNav) {
} }
}, },
month = m, month = m,
row = row,
selected = selected, selected = selected,
hasLessons = { d -> t != null && vm.slotsOn(t, d, subgroup).isNotEmpty() }, hasLessons = { d -> t != null && vm.slotsOn(t, d, subgroup).isNotEmpty() },
hasPlans = { d -> notes.any { it.date == d.toString() && (!it.task || !it.done) } }, hasPlans = { d -> notes.any { it.date == d.toString() && (!it.task || !it.done) } },
weekOf = { m -> t?.days?.firstOrNull { it.date == m.toString() }?.week }, weekOf = { w -> t?.days?.firstOrNull { it.date == w.toString() }?.week },
onSelect = { selected = it }, onSelect = { selected = it },
) )
} }
} }
}
}
val agenda: @Composable (Modifier) -> Unit = { modifier ->
DayAgenda(
vm, t, subgroup, selected, notes,
onLesson = { details = it to selected },
onNote = { editing = it },
modifier = modifier,
)
}
BoxWithConstraints(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.page)) {
if (maxWidth >= TWO_PANE) {
// Tablets: the month on the left, the chosen day in its own pane, as in School Planner.
Column(Modifier.fillMaxSize()) {
appBar()
Row(Modifier.fillMaxSize().padding(end = 16.dp)) {
// Rows stretch to fill the height, as School Planner's month does on a tablet.
BoxWithConstraints(Modifier.weight(1f).fillMaxHeight()) {
val row = ((maxHeight - 40.dp - GRID_BOTTOM - navBarPadding()) / 6).coerceIn(ROW, 112.dp)
monthBlock(false, row)
}
Spacer(Modifier.width(16.dp))
DetailPane(Modifier.weight(1f)) { agenda(Modifier.fillMaxSize().padding(top = 12.dp)) }
}
}
} else Column(Modifier.fillMaxSize().nestedScroll(connection)) {
appBar()
monthBlock(true, ROW)
// The day panel: a sheet with a handle, so its rounded top reads as something that moves. // The day panel: a sheet with a handle, so its rounded top reads as something that moves.
Surface( Surface(
shape = MaterialTheme.shapes.extraLarge.copy(bottomStart = CornerSize(0.dp), bottomEnd = CornerSize(0.dp)), shape = MaterialTheme.shapes.extraLarge.copy(bottomStart = CornerSize(0.dp), bottomEnd = CornerSize(0.dp)),
@@ -264,12 +297,7 @@ fun CalendarScreen(vm: MainViewModel, nav: AppNav) {
) )
}, },
) { BottomSheetDefaults.DragHandle() } ) { BottomSheetDefaults.DragHandle() }
DayAgenda( agenda(Modifier.fillMaxSize())
vm, t, subgroup, selected, notes,
onLesson = { details = it to selected },
onNote = { editing = it },
modifier = Modifier.fillMaxSize(),
)
} }
} }
} }
@@ -316,6 +344,7 @@ fun CalendarScreen(vm: MainViewModel, nav: AppNav) {
private fun MonthGrid( private fun MonthGrid(
modifier: Modifier, modifier: Modifier,
month: YearMonth, month: YearMonth,
row: Dp = ROW,
selected: LocalDate, selected: LocalDate,
hasLessons: (LocalDate) -> Boolean, hasLessons: (LocalDate) -> Boolean,
hasPlans: (LocalDate) -> Boolean, hasPlans: (LocalDate) -> Boolean,
@@ -329,7 +358,7 @@ private fun MonthGrid(
Row( Row(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.height(ROW) .height(row)
.padding(bottom = 2.dp) .padding(bottom = 2.dp)
.background( .background(
if (week != null && !week.white) weekTintOf(week) else Color.Transparent, if (week != null && !week.white) weekTintOf(week) else Color.Transparent,
@@ -400,7 +429,28 @@ private val DATE = DateTimeFormatter.ofPattern("dd.MM.yyyy")
* a day with nothing gets an empty state. * a day with nothing gets an empty state.
*/ */
@Composable @Composable
private fun DayAgenda( internal fun DayAgenda(
vm: MainViewModel,
t: Timetable?,
subgroup: String?,
date: LocalDate,
notes: List<Note>,
onLesson: (Lesson) -> Unit,
onNote: (Note) -> Unit,
modifier: Modifier,
) {
// Another day slides in from the side it lies on: later days from the right.
val slide = rememberSlideDistance()
AnimatedContent(
targetState = date,
transitionSpec = { materialSharedAxisX(forward = targetState > initialState, distance = slide) },
modifier = modifier,
label = "day",
) { day -> DayAgendaContent(vm, t, subgroup, day, notes, onLesson, onNote, Modifier.fillMaxSize()) }
}
@Composable
private fun DayAgendaContent(
vm: MainViewModel, vm: MainViewModel,
t: Timetable?, t: Timetable?,
subgroup: String?, subgroup: String?,
@@ -418,8 +468,8 @@ private fun DayAgenda(
if (plans.isEmpty() && lessons.isEmpty()) { if (plans.isEmpty() && lessons.isEmpty()) {
Box(modifier) { Box(modifier) {
ContentColumn { ContentColumn {
// Scrolls, so a low sheet never squeezes the illustration out of shape. // Centred in the sheet or the tablet's pane; scrolls rather than squeezing the illustration.
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(bottom = 80.dp + navBarPadding())) { CenteredEmpty(bottomInset = 80.dp + navBarPadding()) {
if (known) Empty( if (known) Empty(
"Свободный день", "Свободный день",
"${date.weekdayName()}, ${date.longLabel()}: ни пар, ни планов.", "${date.weekdayName()}, ${date.longLabel()}: ни пар, ни планов.",
@@ -444,9 +494,9 @@ private fun DayAgenda(
if (plans.isNotEmpty()) { if (plans.isNotEmpty()) {
n++ n++
val number = n val number = n
item(key = "plans") { Section(number, "Ваши планы", trailing = date.format(DATE)) } item(key = "plans") { AnimatedItem { Section(number, "Ваши планы", trailing = date.format(DATE)) } }
plans.forEachIndexed { i, note -> plans.forEachIndexed { i, note ->
item(key = note.id) { item(key = note.id) { AnimatedItem {
TimelineItem( TimelineItem(
marker = { PlanMarker(note, onToggle = { vm.toggleNote(note.id) }) }, marker = { PlanMarker(note, onToggle = { vm.toggleNote(note.id) }) },
over = note.subject.ifBlank { null }, over = note.subject.ifBlank { null },
@@ -455,15 +505,15 @@ private fun DayAgenda(
last = i == plans.lastIndex, last = i == plans.lastIndex,
onClick = { onNote(note) }, onClick = { onNote(note) },
) )
} } }
} }
} }
if (lessons.isNotEmpty()) { if (lessons.isNotEmpty()) {
n++ n++
val number = n val number = n
item(key = "lessons") { item(key = "lessons") { AnimatedItem {
Section(number, "Ваши занятия", trailing = if (number == 1) date.format(DATE) else null, gap = number > 1) Section(number, "Ваши занятия", trailing = if (number == 1) date.format(DATE) else null, gap = number > 1)
} } }
lessons.forEachIndexed { i, (slot, l) -> lessons.forEachIndexed { i, (slot, l) ->
item(key = "${slot.start}|${l.key}") { item(key = "${slot.start}|${l.key}") {
val color = subjectColor(l.subject) val color = subjectColor(l.subject)
@@ -555,7 +605,7 @@ private fun TimelineItem(
modifier = Modifier modifier = Modifier
.padding(top = markerTop) .padding(top = markerTop)
.size(MARKER) .size(MARKER)
.background(MaterialTheme.colorScheme.surface, CircleShape), .background(LocalPaneColor.current ?: MaterialTheme.colorScheme.surface, CircleShape),
) { marker() } ) { marker() }
} }
Column(Modifier.weight(1f).padding(start = 16.dp, top = 14.dp, bottom = 10.dp)) { Column(Modifier.weight(1f).padding(start = 16.dp, top = 14.dp, bottom = 10.dp)) {

View File

@@ -1,7 +1,11 @@
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
package ritm.schedule.ui package org.xtimms.psu.schedule.ui
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.text.InlineTextContent import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.material.icons.automirrored.rounded.ArrowForward import androidx.compose.material.icons.automirrored.rounded.ArrowForward
@@ -12,7 +16,7 @@ import androidx.compose.ui.text.PlaceholderVerticalAlign
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.takeOrElse import androidx.compose.ui.unit.takeOrElse
import ritm.schedule.ui.theme.harmonizeWith import org.xtimms.psu.schedule.ui.theme.harmonizeWith
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
@@ -20,19 +24,12 @@ import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.ui.graphics.lerp import androidx.compose.ui.graphics.lerp
import ritm.schedule.ui.theme.page import org.xtimms.psu.schedule.ui.theme.page
import ritm.schedule.ui.theme.card import org.xtimms.psu.schedule.ui.theme.card
import ritm.schedule.ui.theme.page
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.selection.selectable
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.CustomAccessibilityAction import androidx.compose.ui.semantics.CustomAccessibilityAction
import androidx.compose.ui.semantics.customActions import androidx.compose.ui.semantics.customActions
import kotlin.math.abs
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.BoxScope
@@ -41,7 +38,6 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
@@ -78,7 +74,6 @@ import androidx.compose.foundation.text.input.clearText
import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material3.rememberSearchBarState import androidx.compose.material3.rememberSearchBarState
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -88,30 +83,56 @@ import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.selected
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import ritm.schedule.data.Week import org.xtimms.psu.schedule.data.Week
import ritm.schedule.data.shortWeekday import org.xtimms.psu.schedule.data.shortWeekday
import ritm.schedule.data.today import org.xtimms.psu.schedule.data.today
import ritm.schedule.data.weekSpan import org.xtimms.psu.schedule.data.weekSpan
import ritm.schedule.ui.theme.WeekColors import org.xtimms.psu.schedule.ui.theme.WeekColors
import java.time.LocalDate import java.time.LocalDate
/** App bars sit on the tinted page and take a deeper tint once content scrolls under them. */ /** A tablet, by its smallest side: its app bars stay calm, as School Planner's do. */
@Composable
fun isTablet(): Boolean = LocalConfiguration.current.smallestScreenWidthDp >= 600
/**
* App bars sit on the tinted page. On phones they take a deeper tint once content scrolls
* under them; on tablets, where cards float on the page, they stay the page's colour.
*/
@Composable @Composable
fun appBarColors(): TopAppBarColors = TopAppBarDefaults.topAppBarColors( fun appBarColors(): TopAppBarColors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.page, containerColor = MaterialTheme.colorScheme.page,
scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, scrolledContainerColor = if (isTablet()) MaterialTheme.colorScheme.page else MaterialTheme.colorScheme.surfaceContainerHigh,
) )
/** Lists scroll under the translucent navigation bar and end above it. */ /** Lists scroll under the translucent navigation bar and end above it. */
@Composable @Composable
fun navBarPadding(): Dp = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() fun navBarPadding(): Dp = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
/**
* The width a screen's own area needs for two panes side by side (a list and its detail),
* as School Planner lays out its tablet screens. Tablets in landscape have it, phones never.
*/
val TWO_PANE = 840.dp
/** The colour content sits on, so things that cut a line (timeline markers) match it. */
val LocalPaneColor = staticCompositionLocalOf<Color?> { null }
/** The supporting pane of a two-pane screen: a card on the tinted page, like the other cards. */
@Composable
fun DetailPane(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
val color = MaterialTheme.colorScheme.card
Surface(
shape = MaterialTheme.shapes.extraLarge,
color = color,
modifier = modifier.fillMaxHeight().padding(bottom = 16.dp + navBarPadding()),
) { CompositionLocalProvider(LocalPaneColor provides color, content = content) }
}
/** Readable line length on wide windows (M3 keeps content under 840dp). */ /** Readable line length on wide windows (M3 keeps content under 840dp). */
val MAX_CONTENT = 720.dp val MAX_CONTENT = 720.dp

View File

@@ -1,7 +1,8 @@
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
package ritm.schedule.ui package org.xtimms.psu.schedule.ui
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
@@ -9,8 +10,6 @@ import androidx.compose.material.icons.rounded.AccountBalance
import androidx.compose.material.icons.rounded.CheckCircle import androidx.compose.material.icons.rounded.CheckCircle
import androidx.compose.material.icons.rounded.History import androidx.compose.material.icons.rounded.History
import androidx.compose.material.icons.rounded.SearchOff import androidx.compose.material.icons.rounded.SearchOff
import ritm.schedule.ui.theme.card
import ritm.schedule.ui.theme.page
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.ExperimentalLayoutApi
@@ -22,18 +21,13 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.Check
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.Groups import androidx.compose.material.icons.rounded.Groups
import androidx.compose.material.icons.rounded.Search import androidx.compose.material.icons.rounded.Search
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@@ -44,8 +38,6 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
@@ -56,16 +48,14 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import ritm.schedule.data.GroupResult import org.xtimms.psu.schedule.data.GroupResult
import ritm.schedule.data.attempt import org.xtimms.psu.schedule.data.attempt
import ritm.schedule.data.GroupsResponse import org.xtimms.psu.schedule.data.GroupsResponse
import ritm.schedule.data.SavedGroup import org.xtimms.psu.schedule.data.SavedGroup
private val PLAIN_PERIOD = Regex("^(осенний|весенний) семестр$|^расписание из word$", RegexOption.IGNORE_CASE) private val PLAIN_PERIOD = Regex("^(осенний|весенний) семестр$|^расписание из word$", RegexOption.IGNORE_CASE)
@@ -79,7 +69,13 @@ private fun describe(faculty: String, course: String, form: String = "", period:
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class, ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun GroupsScreen(vm: MainViewModel, onOpened: () -> Unit, onBack: (() -> Unit)?) { fun GroupsScreen(
vm: MainViewModel,
onOpened: () -> Unit,
onBack: (() -> Unit)?,
/** A step of the onboarding: its own bar replaces the app bar, and it asks for the subgroup on a page of its own. */
onboarding: Boolean = false,
) {
val settings by vm.settings.collectAsStateWithLifecycle() val settings by vm.settings.collectAsStateWithLifecycle()
val timetable by vm.timetable.collectAsStateWithLifecycle() val timetable by vm.timetable.collectAsStateWithLifecycle()
val status by vm.status.collectAsStateWithLifecycle() val status by vm.status.collectAsStateWithLifecycle()
@@ -91,7 +87,9 @@ fun GroupsScreen(vm: MainViewModel, onOpened: () -> Unit, onBack: (() -> Unit)?)
var error by remember { mutableStateOf<String?>(null) } var error by remember { mutableStateOf<String?>(null) }
var opening by remember { mutableStateOf<String?>(null) } var opening by remember { mutableStateOf<String?>(null) }
LaunchedEffect(query, faculty, course) { // Bumped by "Повторить" to run the same request again.
var retries by remember { mutableIntStateOf(0) }
LaunchedEffect(query, faculty, course, retries) {
if (query.isNotEmpty()) delay(250) if (query.isNotEmpty()) delay(250)
attempt { vm.api.groups(query.trim(), faculty, course) } attempt { vm.api.groups(query.trim(), faculty, course) }
.onSuccess { data = it; error = null } .onSuccess { data = it; error = null }
@@ -107,7 +105,7 @@ fun GroupsScreen(vm: MainViewModel, onOpened: () -> Unit, onBack: (() -> Unit)?)
val subgroups = t?.subgroups.orEmpty() val subgroups = t?.subgroups.orEmpty()
val s = vm.settings.value val s = vm.settings.value
val chosen = s?.current?.group == t?.group && s?.subgroup != null val chosen = s?.current?.group == t?.group && s?.subgroup != null
if (subgroups.isNotEmpty() && !chosen) askSubgroup = subgroups else onOpened() if (subgroups.isNotEmpty() && !chosen && !onboarding) askSubgroup = subgroups else onOpened()
} }
fun open(result: GroupResult) { fun open(result: GroupResult) {
@@ -120,7 +118,7 @@ fun GroupsScreen(vm: MainViewModel, onOpened: () -> Unit, onBack: (() -> Unit)?)
val scroll = TopAppBarDefaults.pinnedScrollBehavior() val scroll = TopAppBarDefaults.pinnedScrollBehavior()
Column(Modifier.fillMaxSize().nestedScroll(scroll.nestedScrollConnection)) { Column(Modifier.fillMaxSize().nestedScroll(scroll.nestedScrollConnection)) {
TopAppBar( if (!onboarding) TopAppBar(
colors = appBarColors(), colors = appBarColors(),
title = { Text("Группы", style = MaterialTheme.typography.titleLargeEmphasized) }, title = { Text("Группы", style = MaterialTheme.typography.titleLargeEmphasized) },
navigationIcon = { navigationIcon = {
@@ -136,7 +134,7 @@ fun GroupsScreen(vm: MainViewModel, onOpened: () -> Unit, onBack: (() -> Unit)?)
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) { ) {
if (current == null) item { if (current == null || onboarding) item {
Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.padding(vertical = 8.dp)) { Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.padding(vertical = 8.dp)) {
Heading("Найдите свою группу", style = MaterialTheme.typography.headlineMediumEmphasized) Heading("Найдите свою группу", style = MaterialTheme.typography.headlineMediumEmphasized)
Text( Text(
@@ -147,9 +145,9 @@ fun GroupsScreen(vm: MainViewModel, onOpened: () -> Unit, onBack: (() -> Unit)?)
} }
} }
item { SearchField(query, { query = it }, "Номер группы, например 23-БА") } item { SearchField(query, { query = it }, "Номер группы, например 23-БА") }
error?.let { item { Banner(it, offline = true, onClose = null) } } error?.let { item { Banner(it, offline = true, onClose = null, onRetry = { error = null; retries++ }) } }
status.message?.let { message -> status.message?.let { message ->
item { Banner(message, offline = status.offline, onClose = vm::dismissMessage) } item { Banner(message, offline = status.offline, onClose = vm::dismissMessage, onRetry = if (status.offline) vm::retry else null) }
} }
if (searching) { if (searching) {

View File

@@ -1,7 +1,13 @@
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
package ritm.schedule.ui package org.xtimms.psu.schedule.ui
import androidx.compose.animation.AnimatedContent
import androidx.compose.material3.IconButton
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowLeft
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.material.icons.rounded.ArrowDropDown import androidx.compose.material.icons.rounded.ArrowDropDown
@@ -14,13 +20,12 @@ import androidx.compose.material.icons.rounded.DateRange
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import ritm.schedule.data.shortWeekday import org.xtimms.psu.schedule.data.shortWeekday
import androidx.compose.material.icons.automirrored.rounded.Assignment import androidx.compose.material.icons.automirrored.rounded.Assignment
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import ritm.schedule.data.Note import org.xtimms.psu.schedule.data.Note
import ritm.schedule.data.parseDate import org.xtimms.psu.schedule.data.parseDate
import ritm.schedule.ui.theme.card import org.xtimms.psu.schedule.ui.theme.card
import ritm.schedule.ui.theme.page
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -38,9 +43,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.automirrored.rounded.MenuBook
import androidx.compose.material.icons.rounded.CalendarViewWeek import androidx.compose.material.icons.rounded.CalendarViewWeek
import androidx.compose.material.icons.rounded.SwapHoriz
import androidx.compose.material.icons.rounded.Today import androidx.compose.material.icons.rounded.Today
import androidx.compose.material.icons.rounded.TravelExplore import androidx.compose.material.icons.rounded.TravelExplore
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
@@ -49,7 +52,6 @@ import androidx.compose.ui.layout.layout
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.EventAvailable import androidx.compose.material.icons.rounded.EventAvailable
import androidx.compose.material.icons.rounded.Insights
import androidx.compose.material.icons.rounded.Description import androidx.compose.material.icons.rounded.Description
import androidx.compose.material.icons.rounded.Groups import androidx.compose.material.icons.rounded.Groups
import androidx.compose.material.icons.rounded.LocationOn import androidx.compose.material.icons.rounded.LocationOn
@@ -60,7 +62,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.LinearWavyProgressIndicator
import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
@@ -85,17 +86,17 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import ritm.schedule.data.Lesson import org.xtimms.psu.schedule.data.Lesson
import ritm.schedule.data.Timetable import org.xtimms.psu.schedule.data.Timetable
import ritm.schedule.data.longLabel import org.xtimms.psu.schedule.data.longLabel
import ritm.schedule.data.minutesLabel import org.xtimms.psu.schedule.data.minutesLabel
import ritm.schedule.data.monday import org.xtimms.psu.schedule.data.monday
import ritm.schedule.data.nowTime import org.xtimms.psu.schedule.data.nowTime
import ritm.schedule.data.parseTime import org.xtimms.psu.schedule.data.parseTime
import ritm.schedule.data.plural import org.xtimms.psu.schedule.data.plural
import ritm.schedule.data.today import org.xtimms.psu.schedule.data.today
import ritm.schedule.data.weekSpan import org.xtimms.psu.schedule.data.weekSpan
import ritm.schedule.data.weekdayName import org.xtimms.psu.schedule.data.weekdayName
import java.time.Duration import java.time.Duration
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalTime import java.time.LocalTime
@@ -172,21 +173,28 @@ fun HomeScreen(vm: MainViewModel, nav: AppNav) {
} }
}, },
) )
status.message?.let { Banner(it, offline = status.offline, onClose = vm::dismissMessage) } status.message?.let { Banner(it, offline = status.offline, onClose = vm::dismissMessage, onRetry = if (status.offline) vm::retry else null) }
val t = timetable val t = timetable
when { when {
t == null && status.loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { LoadingIndicator() } t == null && status.loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { LoadingIndicator() }
t == null -> Empty( t == null -> CenteredEmpty {
Empty(
"Расписание не загружено", "Расписание не загружено",
"Выберите группу — обзор дня и недели появится здесь.", "Выберите группу — обзор дня и недели появится здесь.",
icon = Icons.Rounded.Groups, icon = Icons.Rounded.Groups,
action = "Выбрать группу" to nav.openGroups, // A chosen group that failed to load gets another try; otherwise pick one.
action = if (settings?.current != null) "Повторить" to { vm.refresh(); Unit } else "Выбрать группу" to nav.openGroups,
) )
}
t.semester == null -> Column(Modifier.padding(top = 8.dp)) { t.semester == null -> Column(Modifier.padding(top = 8.dp)) {
Banner(t.note ?: "Даты для этого расписания не рассчитаны.", offline = false, onClose = null) Banner(t.note ?: "Даты для этого расписания не рассчитаны.", offline = false, onClose = null)
CenteredEmpty {
Empty("Обзор недоступен", "Строки Word по дням недели — во вкладке «Расписание».", icon = Icons.Rounded.Description, action = "Открыть" to { nav.openDay(date) }) Empty("Обзор недоступен", "Строки Word по дням недели — во вкладке «Расписание».", icon = Icons.Rounded.Description, action = "Открыть" to { nav.openDay(date) })
} }
else -> ContentColumn { }
else -> BoxWithConstraints(Modifier.fillMaxSize()) {
val twoPane = maxWidth >= TWO_PANE
val overview: @Composable (Modifier) -> Unit = { listModifier ->
val todaySlots = vm.slotsOn(t, date, subgroup) val todaySlots = vm.slotsOn(t, date, subgroup)
val next = (1..7).asSequence() val next = (1..7).asSequence()
.map { date.plusDays(it.toLong()) } .map { date.plusDays(it.toLong()) }
@@ -195,19 +203,22 @@ fun HomeScreen(vm: MainViewModel, nav: AppNav) {
LazyColumn( LazyColumn(
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 24.dp + navBarPadding()), contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 24.dp + navBarPadding()),
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.fillMaxSize(), modifier = listModifier,
) { ) {
item { QuickActions(nav, Modifier.bleed(16.dp)) } // Shortcuts are a phone touch: on a tablet the menu and the week pane are always in view.
item { WeekReport(vm, t, subgroup, date, clock) } // Cards that appear, empty out or grow (attendance once marked, plans as they
item { SessionCard(vm, t, onOpen = { session = true }) } // are ticked off) ease the rest of the overview into place.
item { DayTitle("Сегодня", date) } if (!twoPane) item(key = "actions") { QuickActions(nav, Modifier.bleed(16.dp)) }
item { TodayCard(todaySlots, clock, onOpen = { details = it to date }, onDay = { nav.openDay(date) }) } item(key = "week") { AnimatedItem { WeekReport(vm, t, subgroup, date, clock) } }
item { AttendanceCard(vm, todaySlots) } item(key = "session") { AnimatedItem { SessionCard(vm, t, onOpen = { session = true }) } }
item { TasksCard(vm, onEdit = { editing = it }, onAll = nav.openPlans) } item(key = "today-title") { AnimatedItem { DayTitle("Сегодня", date) } }
item(key = "today") { AnimatedItem { TodayCard(todaySlots, clock, onOpen = { details = it to date }, onDay = { nav.openDay(date) }) } }
item(key = "attendance") { AnimatedItem { AttendanceCard(vm, todaySlots) } }
item(key = "tasks") { AnimatedItem { TasksCard(vm, onEdit = { editing = it }, onAll = nav.openPlans) } }
if (next != null) { if (next != null) {
val (day, slots) = next val (day, slots) = next
item { DayTitle(if (day == date.plusDays(1)) "Завтра" else day.weekdayName(), day) } item(key = "next-title") { AnimatedItem { DayTitle(if (day == date.plusDays(1)) "Завтра" else day.weekdayName(), day) } }
item { item(key = "next") {
SectionCard(Icons.Rounded.EventAvailable, plural(slots.size, "пара", "пары", "пар").let { "${slots.size} $it" }) { SectionCard(Icons.Rounded.EventAvailable, plural(slots.size, "пара", "пары", "пар").let { "${slots.size} $it" }) {
slots.forEach { slot -> slot.lessons.forEach { CompactLesson(it, slot, onClick = { details = it to day }) } } slots.forEach { slot -> slot.lessons.forEach { CompactLesson(it, slot, onClick = { details = it to day }) } }
DayButton { nav.openDay(day) } DayButton { nav.openDay(day) }
@@ -216,6 +227,14 @@ fun HomeScreen(vm: MainViewModel, nav: AppNav) {
} }
} }
} }
if (twoPane) Row(Modifier.fillMaxSize()) {
// Tablets: the overview beside this week's grid, as School Planner pairs them.
overview(Modifier.width(440.dp).fillMaxHeight())
DetailPane(Modifier.weight(1f).padding(end = 16.dp)) {
WeekPane(vm, t, subgroup, onOpen = { lesson, day -> details = lesson to day })
}
} else ContentColumn { overview(Modifier.fillMaxSize()) }
}
} }
} }
if (session) timetable?.let { t -> if (session) timetable?.let { t ->
@@ -549,3 +568,48 @@ private fun HomeMeta(icon: ImageVector, text: String) {
Text(text, style = MaterialTheme.typography.bodyMedium) Text(text, style = MaterialTheme.typography.bodyMedium)
} }
} }
/** This week's grid in the overview's side pane; on Sunday it already shows the coming week. */
@Composable
private fun WeekPane(vm: MainViewModel, t: Timetable, subgroup: String?, onOpen: (Lesson, LocalDate) -> Unit) {
val first = remember { (if (today().dayOfWeek.value == 7) today().plusDays(1) else today()).monday() }
var monday by remember { mutableStateOf(first) }
val loaded = remember(t) { t.days.map { parseDate(it.date) }.toSet() }
val week = t.days.firstOrNull { it.date == monday.toString() }?.week
Column(Modifier.fillMaxSize()) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(start = 20.dp, end = 8.dp, top = 12.dp)) {
Column(Modifier.weight(1f)) {
Heading(week?.let { "Неделя ${it.n}" } ?: "Неделя", style = MaterialTheme.typography.titleMediumEmphasized)
Row(verticalAlignment = Alignment.CenterVertically) {
Text(weekSpan(monday, monday.plusDays(6)), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
week?.let {
Spacer(Modifier.width(10.dp))
WeekMark(it, short = true)
}
}
}
if (monday != first) IconButton(onClick = { monday = first }) { Icon(Icons.Rounded.Today, contentDescription = "Эта неделя") }
IconButton(onClick = { monday = monday.minusDays(7) }, enabled = monday.minusDays(7) in loaded) {
Icon(Icons.AutoMirrored.Rounded.KeyboardArrowLeft, contentDescription = "Предыдущая неделя")
}
IconButton(onClick = { monday = monday.plusDays(7) }, enabled = monday.plusDays(7) in loaded) {
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),
)
}
}
}

View File

@@ -1,4 +1,4 @@
package ritm.schedule.ui package org.xtimms.psu.schedule.ui
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -10,19 +10,19 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import ritm.schedule.data.Note import org.xtimms.psu.schedule.data.Note
import ritm.schedule.data.groupKey import org.xtimms.psu.schedule.data.groupKey
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import ritm.schedule.app import org.xtimms.psu.schedule.app
import ritm.schedule.data.ApiException import org.xtimms.psu.schedule.data.ApiException
import ritm.schedule.data.Display import org.xtimms.psu.schedule.data.Display
import ritm.schedule.data.GroupResult import org.xtimms.psu.schedule.data.GroupResult
import ritm.schedule.data.SavedGroup import org.xtimms.psu.schedule.data.SavedGroup
import ritm.schedule.data.ThemeMode import org.xtimms.psu.schedule.data.ThemeMode
import ritm.schedule.data.Timetable import org.xtimms.psu.schedule.data.Timetable
import ritm.schedule.data.UserSettings import org.xtimms.psu.schedule.data.UserSettings
import ritm.schedule.work.SyncWorker import org.xtimms.psu.schedule.work.SyncWorker
data class Status(val loading: Boolean = false, val message: String? = null, val offline: Boolean = false) data class Status(val loading: Boolean = false, val message: String? = null, val offline: Boolean = false)
@@ -37,7 +37,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
val timetable: StateFlow<Timetable?> = app.repository.timetable val timetable: StateFlow<Timetable?> = app.repository.timetable
/** The open group's exams; null until the first answer, then cached on the device. */ /** The open group's exams; null until the first answer, then cached on the device. */
val exams: StateFlow<ritm.schedule.data.ExamsResponse?> = app.repository.exams val exams: StateFlow<org.xtimms.psu.schedule.data.ExamsResponse?> = app.repository.exams
/** Tasks and notes of the open group. */ /** Tasks and notes of the open group. */
val notes: StateFlow<List<Note>> = combine(app.notes.notes, app.settings.flow) { list, s -> val notes: StateFlow<List<Note>> = combine(app.notes.notes, app.settings.flow) { list, s ->
@@ -46,15 +46,15 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
}.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) }.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
/** Attendance marks of the open group. */ /** Attendance marks of the open group. */
val marks: StateFlow<List<ritm.schedule.data.Mark>> = combine(app.attendance.marks, app.settings.flow) { list, s -> val marks: StateFlow<List<org.xtimms.psu.schedule.data.Mark>> = combine(app.attendance.marks, app.settings.flow) { list, s ->
val key = s.current?.let(::groupKey) val key = s.current?.let(::groupKey)
list.filter { it.group == key } list.filter { it.group == key }
}.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) }.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
/** Marks a lesson as attended (true), missed (false) or clears the mark (null). */ /** Marks a lesson as attended (true), missed (false) or clears the mark (null). */
fun mark(lesson: ritm.schedule.data.Lesson, date: java.time.LocalDate, present: Boolean?) = viewModelScope.launch { fun mark(lesson: org.xtimms.psu.schedule.data.Lesson, date: java.time.LocalDate, present: Boolean?) = viewModelScope.launch {
val group = currentGroupKey() ?: return@launch val group = currentGroupKey() ?: return@launch
app.attendance.set(ritm.schedule.data.Mark(group, lesson.subject, date.toString(), lesson.start, present ?: false), present) app.attendance.set(org.xtimms.psu.schedule.data.Mark(group, lesson.subject, date.toString(), lesson.start, present ?: false), present)
} }
fun saveNote(note: Note) = viewModelScope.launch { app.notes.save(note) } fun saveNote(note: Note) = viewModelScope.launch { app.notes.save(note) }
@@ -94,14 +94,26 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
done() done()
} }
// The last request that failed, so a "Повторить" button can run it again.
private var lastFailed: (suspend () -> Result<Timetable>)? = null
/** Runs the request that failed last once more (the banners' "Повторить"). */
fun retry() {
val block = lastFailed ?: return
viewModelScope.launch { perform(block) }
}
private suspend fun perform(block: suspend () -> Result<Timetable>): Boolean { private suspend fun perform(block: suspend () -> Result<Timetable>): Boolean {
_status.value = _status.value.copy(loading = true) _status.value = _status.value.copy(loading = true)
val result = block() val result = block()
val error = result.exceptionOrNull() val error = result.exceptionOrNull()
val offline = (error as? ApiException)?.offline == true
lastFailed = if (error != null) block else null
_status.value = Status( _status.value = Status(
loading = false, loading = false,
message = error?.message, // Offline with a timetable on the device still leaves something to look at.
offline = (error as? ApiException)?.offline == true, message = error?.message?.let { if (offline && timetable.value != null) "$it Показана сохранённая копия." else it },
offline = offline,
) )
SyncWorker.afterChange(getApplication()) SyncWorker.afterChange(getApplication())
return result.isSuccess return result.isSuccess
@@ -119,7 +131,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun setTheme(mode: ThemeMode) = viewModelScope.launch { app.settings.setTheme(mode) } fun setTheme(mode: ThemeMode) = viewModelScope.launch { app.settings.setTheme(mode) }
fun setDynamicColor(on: Boolean) = viewModelScope.launch { app.settings.setDynamicColor(on) } fun setDynamicColor(on: Boolean) = viewModelScope.launch { app.settings.setDynamicColor(on) }
fun setDisplay(display: Display) = viewModelScope.launch { app.settings.setDisplay(display) } fun setDisplay(display: Display) = viewModelScope.launch { app.settings.setDisplay(display) }
fun setPlans(prefs: ritm.schedule.data.PlansPrefs) = viewModelScope.launch { app.settings.setPlans(prefs) } fun setPlans(prefs: org.xtimms.psu.schedule.data.PlansPrefs) = viewModelScope.launch { app.settings.setPlans(prefs) }
fun setHidden(key: String, hidden: Boolean) = viewModelScope.launch { fun setHidden(key: String, hidden: Boolean) = viewModelScope.launch {
app.settings.setHidden(key, hidden) app.settings.setHidden(key, hidden)

View File

@@ -1,5 +1,12 @@
package ritm.schedule.ui package org.xtimms.psu.schedule.ui
import androidx.compose.ui.Modifier
import androidx.compose.material3.MaterialTheme
import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridItemScope
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.layout.Box
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.ContentTransform import androidx.compose.animation.ContentTransform
import androidx.compose.animation.EnterTransition import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition import androidx.compose.animation.ExitTransition
@@ -32,17 +39,22 @@ fun rememberSlideDistance(): Int {
return remember(density) { with(density) { 30.dp.roundToPx() } } return remember(density) { with(density) { 30.dp.roundToPx() } }
} }
private fun sharedAxisXIn(forward: Boolean, distance: Int): EnterTransition = // A predictive back gesture can pause anywhere, so its fades overlap over the whole
// transition instead of handing over in the middle, as RideBus does.
private fun sharedAxisXIn(forward: Boolean, distance: Int, predictive: Boolean): EnterTransition =
slideInHorizontally(tween(300, easing = FastOutSlowInEasing)) { if (forward) distance else -distance } + slideInHorizontally(tween(300, easing = FastOutSlowInEasing)) { if (forward) distance else -distance } +
fadeIn(tween(durationMillis = 195, delayMillis = 105, easing = LinearOutSlowInEasing)) fadeIn(
if (predictive) tween(300, easing = LinearEasing)
else tween(durationMillis = 195, delayMillis = 105, easing = LinearOutSlowInEasing),
)
private fun sharedAxisXOut(forward: Boolean, distance: Int): ExitTransition = private fun sharedAxisXOut(forward: Boolean, distance: Int, predictive: Boolean): ExitTransition =
slideOutHorizontally(tween(300, easing = FastOutSlowInEasing)) { if (forward) -distance else distance } + slideOutHorizontally(tween(300, easing = FastOutSlowInEasing)) { if (forward) -distance else distance } +
fadeOut(tween(durationMillis = 105, easing = FastOutLinearInEasing)) fadeOut(if (predictive) tween(300, easing = LinearEasing) else tween(durationMillis = 105, easing = FastOutLinearInEasing))
/** Forward into a page slides in from the end; back slides the other way. */ /** Forward into a page slides in from the end; back slides the other way. */
fun materialSharedAxisX(forward: Boolean, distance: Int): ContentTransform = fun materialSharedAxisX(forward: Boolean, distance: Int, predictive: Boolean = false): ContentTransform =
sharedAxisXIn(forward, distance) togetherWith sharedAxisXOut(forward, distance) sharedAxisXIn(forward, distance, predictive) togetherWith sharedAxisXOut(forward, distance, predictive)
/** /**
* md.sys.motion.easing.emphasized: the two-segment path M3 uses for large transitions that * md.sys.motion.easing.emphasized: the two-segment path M3 uses for large transitions that
@@ -70,6 +82,22 @@ val EmphasizedDecelerate = CubicBezierEasing(0.05f, 0.7f, 0.1f, 1f)
/** M3 emphasized easing for components leaving: they speed up and come to rest just off screen. */ /** M3 emphasized easing for components leaving: they speed up and come to rest just off screen. */
val EmphasizedAccelerate = CubicBezierEasing(0.3f, 0f, 0.8f, 0.15f) val EmphasizedAccelerate = CubicBezierEasing(0.3f, 0f, 0.8f, 0.15f)
/**
* A list item that eases into place: it fades in and out as it appears or leaves, slides
* when items around it change, and grows or shrinks with its content (a card that fills
* up or empties) instead of jumping.
*/
@Composable
fun LazyItemScope.AnimatedItem(content: @Composable () -> Unit) {
Box(Modifier.animateItem().animateContentSize(MaterialTheme.motionScheme.defaultSpatialSpec())) { content() }
}
/** The same for a grid item. */
@Composable
fun LazyStaggeredGridItemScope.AnimatedItem(content: @Composable () -> Unit) {
Box(Modifier.animateItem().animateContentSize(MaterialTheme.motionScheme.defaultSpatialSpec())) { content() }
}
/** Between sibling destinations: the old one fades out, the new one fades and grows in. */ /** Between sibling destinations: the old one fades out, the new one fades and grows in. */
fun materialFadeThrough(): ContentTransform = fun materialFadeThrough(): ContentTransform =
(fadeIn(tween(400, easing = EmphasizedDecelerate)) + scaleIn(tween(400, easing = EmphasizedDecelerate), initialScale = 0.92f)) togetherWith (fadeIn(tween(400, easing = EmphasizedDecelerate)) + scaleIn(tween(400, easing = EmphasizedDecelerate), initialScale = 0.92f)) togetherWith

View File

@@ -1,10 +1,7 @@
package ritm.schedule.ui package org.xtimms.psu.schedule.ui
import androidx.compose.foundation.layout.RowScope
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Menu import androidx.compose.material.icons.rounded.Menu
import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material.icons.rounded.SwapHoriz
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable

View File

@@ -1,6 +1,6 @@
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
package ritm.schedule.ui package org.xtimms.psu.schedule.ui
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@@ -52,15 +52,15 @@ import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import ritm.schedule.data.Lesson import org.xtimms.psu.schedule.data.Lesson
import ritm.schedule.data.Note import org.xtimms.psu.schedule.data.Note
import ritm.schedule.data.Timetable import org.xtimms.psu.schedule.data.Timetable
import ritm.schedule.data.longLabel import org.xtimms.psu.schedule.data.longLabel
import ritm.schedule.data.nowTime import org.xtimms.psu.schedule.data.nowTime
import ritm.schedule.data.parseDate import org.xtimms.psu.schedule.data.parseDate
import ritm.schedule.data.shortWeekday import org.xtimms.psu.schedule.data.shortWeekday
import ritm.schedule.data.today import org.xtimms.psu.schedule.data.today
import ritm.schedule.data.weekdayName import org.xtimms.psu.schedule.data.weekdayName
import java.time.LocalDate import java.time.LocalDate
/** Tasks and notes of the open group, for cards deep in the tree. */ /** Tasks and notes of the open group, for cards deep in the tree. */

View File

@@ -0,0 +1,475 @@
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
package org.xtimms.psu.schedule.ui
import android.Manifest
import android.os.Build
import android.provider.Settings
import androidx.activity.compose.PredictiveBackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.SeekableTransitionState
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.rememberTransition
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
import androidx.compose.material.icons.rounded.CalendarMonth
import androidx.compose.material.icons.rounded.Checklist
import androidx.compose.material.icons.rounded.Groups
import androidx.compose.material.icons.rounded.NotificationsActive
import androidx.compose.material.icons.rounded.DownloadForOffline
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearWavyProgressIndicator
import androidx.compose.material3.MaterialShapes
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.toShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.launch
import org.xtimms.psu.schedule.notify.Reminders
import org.xtimms.psu.schedule.ui.theme.WeekColors
import org.xtimms.psu.schedule.ui.theme.page
import kotlin.coroutines.cancellation.CancellationException
/** The first run, in the order a student needs it: what this is, their group, their subgroup, reminders. */
private enum class Step { Welcome, Group, Subgroup, Reminders }
/** Text and controls of the onboarding pages keep to a phone's width on tablets. */
private val PAGE_WIDTH = 560.dp
/**
* First-run onboarding. Only the group is required: the welcome leads straight to it,
* the subgroup page appears only for groups split into subgroups, and reminders can wait.
* [onDone] opens the overview with the chosen group.
*/
@Composable
fun Onboarding(vm: MainViewModel, onDone: () -> Unit) {
val timetable by vm.timetable.collectAsStateWithLifecycle()
val settings by vm.settings.collectAsStateWithLifecycle()
val subgroups = timetable?.subgroups.orEmpty()
var step by rememberSaveable { mutableStateOf(Step.Welcome) }
// The steps the progress bar counts; the welcome is not one of them.
val steps = if (subgroups.isEmpty()) listOf(Step.Group, Step.Reminders) else listOf(Step.Group, Step.Subgroup, Step.Reminders)
fun previous(of: Step): Step = when (of) {
Step.Welcome, Step.Group -> Step.Welcome
Step.Subgroup -> Step.Group
Step.Reminders -> if (subgroups.isEmpty()) Step.Group else Step.Subgroup
}
// Predictive back, as in the rest of the app: the gesture scrubs the way back to the previous step.
val state = remember { SeekableTransitionState(step) }
var gesture by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
LaunchedEffect(step) { state.animateTo(step) }
PredictiveBackHandler(enabled = step != Step.Welcome) { events ->
val back = previous(step)
gesture = true
try {
events.collect { state.seekTo(it.progress, targetState = back) }
step = back
} catch (e: CancellationException) {
scope.launch { state.animateTo(state.currentState) }
throw e
} finally {
gesture = false
}
}
val slide = rememberSlideDistance()
Column(
Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)),
) {
// One bar for all the steps, so its progress travels from step to step instead of jumping.
AnimatedVisibility(
visible = step != Step.Welcome,
enter = fadeIn() + expandVertically(expandFrom = Alignment.Top),
exit = fadeOut() + shrinkVertically(shrinkTowards = Alignment.Top),
) {
StepBar(
index = steps.indexOf(step).coerceAtLeast(0),
count = steps.size,
onBack = { step = previous(step) },
)
}
rememberTransition(state, label = "onboarding").AnimatedContent(
transitionSpec = { materialSharedAxisX(forward = targetState > initialState, distance = slide, predictive = gesture) },
modifier = Modifier.weight(1f),
) { shown ->
when (shown) {
Step.Welcome -> Welcome(onStart = { step = Step.Group })
Step.Group -> GroupsScreen(
vm,
onOpened = { step = if (vm.timetable.value?.subgroups.isNullOrEmpty()) Step.Reminders else Step.Subgroup },
onBack = null,
onboarding = true,
)
Step.Subgroup -> SubgroupStep(
group = timetable?.group.orEmpty(),
subgroups = subgroups,
selected = settings?.subgroup,
onPick = { vm.setSubgroup(it); step = Step.Reminders },
)
Step.Reminders -> RemindersStep(
minutes = settings?.reminderMinutes ?: 10,
onMinutes = vm::setReminderMinutes,
onDone = { on -> vm.setReminders(on); onDone() },
)
}
}
}
}
/** Back to the previous step and how far along the student is, as a wavy M3 Expressive bar. */
@Composable
private fun StepBar(index: Int, count: Int, onBack: () -> Unit) {
val progress by animateFloatAsState(
(index + 1f) / count,
MaterialTheme.motionScheme.slowSpatialSpec(),
label = "onboarding-progress",
)
Row(
Modifier.fillMaxWidth().height(64.dp).padding(start = 4.dp, end = 24.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Назад") }
Spacer(Modifier.width(12.dp))
LinearWavyProgressIndicator(progress = { progress }, modifier = Modifier.weight(1f).clearAndSetSemantics {})
Spacer(Modifier.width(16.dp))
Text(
"${index + 1} из $count",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
/**
* A page of the onboarding: its content above, its buttons at the bottom. On short screens
* (a phone in landscape) the whole page scrolls instead of squeezing.
*/
@Composable
private fun StepPage(
centered: Boolean,
buttons: @Composable ColumnScope.() -> Unit,
content: @Composable ColumnScope.() -> Unit,
) {
BoxWithConstraints(Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) {
val height = maxHeight
Column(
Modifier
.widthIn(max = PAGE_WIDTH)
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.heightIn(min = height)
// Step pages line up with the group list's 16dp; the welcome breathes a little wider.
.padding(horizontal = if (centered) 24.dp else 16.dp)
.padding(top = 8.dp, bottom = 24.dp + navBarPadding()),
// Centred pages float their content midway between the top and the buttons.
verticalArrangement = Arrangement.SpaceBetween,
) {
if (centered) Spacer(Modifier.height(16.dp))
Column(
Modifier.fillMaxWidth(),
horizontalAlignment = if (centered) Alignment.CenterHorizontally else Alignment.Start,
content = content,
)
Column(
Modifier.fillMaxWidth().padding(top = 32.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
content = buttons,
)
}
}
}
/** The big M3 Expressive button the pages end with. */
@Composable
private fun PrimaryButton(text: String, onClick: () -> Unit) {
val height = ButtonDefaults.MediumContainerHeight
Button(
onClick = onClick,
shapes = ButtonDefaults.shapesFor(height),
contentPadding = ButtonDefaults.contentPaddingFor(height),
modifier = Modifier.fillMaxWidth().heightIn(min = height),
) {
Text(text, style = ButtonDefaults.textStyleFor(height))
}
}
// The three things the app is for, each with its own colour: the blue of the app,
// the green of the university's green weeks and the tertiary accent.
private class Feature(val icon: ImageVector, val title: String, val text: String)
private val FEATURES = listOf(
Feature(Icons.Rounded.CalendarMonth, "Пары на каждый день", "Даты уже рассчитаны по белым и зелёным неделям"),
Feature(Icons.Rounded.Checklist, "Задания к занятиям", "Планы и заметки рядом с предметами"),
Feature(Icons.Rounded.DownloadForOffline, "Без интернета", "Расписание хранится на телефоне"),
)
@Composable
private fun featureColors(index: Int): Pair<Color, Color> {
val scheme = MaterialTheme.colorScheme
val dark = scheme.surface.luminance() < 0.5f
return when (index) {
0 -> scheme.primaryContainer to scheme.onPrimaryContainer
1 -> WeekColors.container(white = false, dark = dark) to WeekColors.content(white = false, dark = dark)
else -> scheme.tertiaryContainer to scheme.onTertiaryContainer
}
}
@Composable
private fun Welcome(onStart: () -> Unit) {
StepPage(
centered = true,
buttons = {
PrimaryButton("Найти свою группу", onStart)
Text(
"Неофициальное приложение. Расписание берётся из публичного каталога ПГУ.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
},
) {
Hero()
Spacer(Modifier.height(32.dp))
Heading("Расписание ПГУ", style = MaterialTheme.typography.displaySmallEmphasized)
Spacer(Modifier.height(8.dp))
Text(
"Пары вашей группы из документов университета — без таблиц Word на маленьком экране.",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(32.dp))
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(20.dp)) {
FEATURES.forEachIndexed { i, f ->
val (container, content) = featureColors(i)
Row(verticalAlignment = Alignment.CenterVertically) {
Badge(f.icon, MaterialShapes.Cookie6Sided.toShape(), container, content, size = 48.dp, icon = 24.dp)
Spacer(Modifier.width(16.dp))
Column(Modifier.weight(1f)) {
Text(f.title, style = MaterialTheme.typography.titleMediumEmphasized)
Text(f.text, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
}
}
/** Animations are off in the system settings: the hero stands still. */
@Composable
private fun animationsOff(): Boolean {
val resolver = LocalContext.current.contentResolver
return remember { Settings.Global.getFloat(resolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) == 0f }
}
/**
* The welcome's picture: the features' shapes gathered around a slowly turning cookie,
* the same M3 Expressive shapes the app's empty states use.
*/
@Composable
private fun Hero() {
val still = animationsOff()
val turn = rememberInfiniteTransition(label = "hero")
val angle by turn.animateFloat(
initialValue = 0f,
targetValue = 360f,
animationSpec = infiniteRepeatable(tween(40_000, easing = LinearEasing)),
label = "hero-angle",
)
val (blue, onBlue) = featureColors(0)
val (green, onGreen) = featureColors(1)
val (accent, onAccent) = featureColors(2)
Box(Modifier.size(width = 248.dp, height = 208.dp).clearAndSetSemantics {}) {
Box(
Modifier
.align(Alignment.Center)
.size(176.dp)
.graphicsLayer { if (!still) rotationZ = angle }
.background(blue, MaterialShapes.Cookie9Sided.toShape()),
)
Icon(
Icons.Rounded.CalendarMonth,
contentDescription = null,
tint = onBlue,
modifier = Modifier.align(Alignment.Center).size(72.dp),
)
Badge(
Icons.Rounded.Checklist, MaterialShapes.Sunny.toShape(), green, onGreen,
size = 80.dp, icon = 32.dp, ring = true,
modifier = Modifier.align(Alignment.TopEnd),
)
Badge(
Icons.Rounded.DownloadForOffline, MaterialShapes.Clover4Leaf.toShape(), accent, onAccent,
size = 68.dp, icon = 28.dp, ring = true,
modifier = Modifier.align(Alignment.BottomStart),
)
}
}
/**
* An icon on an expressive shape. With [ring] it is cut out of what lies beneath by a
* band of the page's colour, as the timeline markers cut their line.
*/
@Composable
private fun Badge(
image: ImageVector,
shape: Shape,
color: Color,
content: Color,
size: Dp,
icon: Dp,
modifier: Modifier = Modifier,
ring: Boolean = false,
) {
Box(
modifier
.size(size)
.then(if (ring) Modifier.background(MaterialTheme.colorScheme.page, shape).padding(5.dp) else Modifier)
.background(color, shape),
contentAlignment = Alignment.Center,
) {
Icon(image, contentDescription = null, tint = content, modifier = Modifier.size(icon))
}
}
/** The heading of a step page: its shape and icon, a title and what the choice changes. */
@Composable
private fun StepHeading(icon: ImageVector, title: String, text: String) {
val scheme = MaterialTheme.colorScheme
Badge(icon, MaterialShapes.Cookie7Sided.toShape(), scheme.primaryContainer, scheme.onPrimaryContainer, size = 72.dp, icon = 32.dp)
Spacer(Modifier.height(24.dp))
Heading(title, style = MaterialTheme.typography.headlineMediumEmphasized)
Spacer(Modifier.height(8.dp))
Text(text, style = MaterialTheme.typography.bodyLarge, color = scheme.onSurfaceVariant)
Spacer(Modifier.height(24.dp))
}
/** "Ваша подгруппа" as a page: one tap picks it and moves on. */
@Composable
private fun SubgroupStep(group: String, subgroups: List<String>, selected: String?, onPick: (String?) -> Unit) {
StepPage(
centered = false,
buttons = { TextButton(onClick = { onPick(selected) }) { Text("Пропустить") } },
) {
StepHeading(
Icons.Rounded.Groups,
"Ваша подгруппа",
"В группе $group часть занятий идёт по подгруппам. Выберите свою — чужие пары скроются из расписания, " +
"виджета и напоминаний. Изменить можно в разделе «Группы».",
)
SegmentedList {
val options = listOf<String?>(null) + subgroups
options.forEachIndexed { i, option ->
SegmentedItem(
index = i,
count = options.size,
headline = option ?: "Все подгруппы",
supporting = if (option == null) "Показывать занятия всех подгрупп" else null,
onClick = { onPick(option) },
trailing = { Icon(Icons.AutoMirrored.Rounded.KeyboardArrowRight, contentDescription = null) },
)
}
}
}
}
/** Reminders before each lesson; Android asks for the notification permission here, when it makes sense. */
@Composable
private fun RemindersStep(minutes: Int, onMinutes: (Int) -> Unit, onDone: (Boolean) -> Unit) {
val context = LocalContext.current
val permission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> onDone(granted) }
StepPage(
centered = false,
buttons = {
PrimaryButton("Включить напоминания") {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !Reminders.canNotify(context))
permission.launch(Manifest.permission.POST_NOTIFICATIONS)
else onDone(true)
}
TextButton(onClick = { onDone(false) }) { Text("Не сейчас") }
},
) {
StepHeading(
Icons.Rounded.NotificationsActive,
"Напоминать о парах?",
"Уведомление с предметом и аудиторией перед началом пары. Работает без интернета, по сохранённому расписанию. " +
"Выключить можно в настройках.",
)
Text("За сколько минут", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.height(8.dp))
ConnectedChoice(
options = listOf(5 to "5", 10 to "10", 15 to "15", 30 to "30"),
selected = minutes,
onSelect = onMinutes,
modifier = Modifier.fillMaxWidth(),
)
}
}

View File

@@ -1,7 +1,18 @@
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
package ritm.schedule.ui package org.xtimms.psu.schedule.ui
import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.fadeOut
import androidx.compose.animation.fadeIn
import androidx.compose.animation.expandVertically
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.foundation.pager.PageSize
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.material.icons.rounded.EventNote import androidx.compose.material.icons.rounded.EventNote
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
@@ -70,19 +81,19 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import ritm.schedule.data.Lesson import org.xtimms.psu.schedule.data.Lesson
import ritm.schedule.data.Note import org.xtimms.psu.schedule.data.Note
import ritm.schedule.data.PlansLayout import org.xtimms.psu.schedule.data.PlansLayout
import ritm.schedule.data.PlansPrefs import org.xtimms.psu.schedule.data.PlansPrefs
import ritm.schedule.data.Timetable import org.xtimms.psu.schedule.data.Timetable
import ritm.schedule.data.longLabel import org.xtimms.psu.schedule.data.longLabel
import ritm.schedule.data.monday import org.xtimms.psu.schedule.data.monday
import ritm.schedule.data.parseDate import org.xtimms.psu.schedule.data.parseDate
import ritm.schedule.data.plural import org.xtimms.psu.schedule.data.plural
import ritm.schedule.data.today import org.xtimms.psu.schedule.data.today
import ritm.schedule.data.weekdayName import org.xtimms.psu.schedule.data.weekdayName
import ritm.schedule.ui.theme.card import org.xtimms.psu.schedule.ui.theme.card
import ritm.schedule.ui.theme.page import org.xtimms.psu.schedule.ui.theme.page
import java.time.LocalDate import java.time.LocalDate
/** "Сегодня • Суббота • 17 октября", "Завтра • …" or "Понедельник • 19 октября". */ /** "Сегодня • Суббота • 17 октября", "Завтра • …" or "Понедельник • 19 октября". */
@@ -115,9 +126,13 @@ fun PlansScreen(vm: MainViewModel, nav: AppNav) {
var editing by remember { mutableStateOf<Note?>(null) } var editing by remember { mutableStateOf<Note?>(null) }
var adding by remember { mutableStateOf(false) } var adding by remember { mutableStateOf(false) }
var tuning by remember { mutableStateOf(false) } var tuning by remember { mutableStateOf(false) }
var details by remember { mutableStateOf<Pair<Lesson, LocalDate>?>(null) }
var target by remember { mutableStateOf<NoteTarget?>(null) } var target by remember { mutableStateOf<NoteTarget?>(null) }
var date by remember { mutableStateOf(today()) } var date by remember { mutableStateOf(today()) }
val list = rememberLazyListState() // Each layout keeps its own list, so the outgoing and incoming layouts can be on screen
// together while one fades through to the other.
val agendaList = rememberLazyListState()
val plainList = rememberLazyListState()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val notes = if (prefs.done) all else all.filterNot { it.task && it.done } val notes = if (prefs.done) all else all.filterNot { it.task && it.done }
@@ -135,13 +150,10 @@ fun PlansScreen(vm: MainViewModel, nav: AppNav) {
val agenda = prefs.layout == PlansLayout.Agenda val agenda = prefs.layout == PlansLayout.Agenda
val week = timetable?.days?.firstOrNull { it.date == date.monday().toString() }?.week val week = timetable?.days?.firstOrNull { it.date == date.monday().toString() }?.week
// Built once per data change, not on every day the strip passes while the list scrolls. // Built once per data change, not on every day the strip passes while the list scrolls.
val rows = remember(all, timetable, prefs, settings?.subgroup, vm.hidden) { val agendaRows = remember(all, timetable, prefs, settings?.subgroup, vm.hidden) { rows(days, byDay, overdue, lessonsOf, agenda = true) }
if (prefs.layout == PlansLayout.Board) emptyList() else rows(days, byDay, overdue, lessonsOf, agenda) val plainRows = remember(all, timetable, prefs, settings?.subgroup, vm.hidden) { rows(days, byDay, overdue, lessonsOf, agenda = false) }
}
Box(Modifier.fillMaxSize()) { val appBar: @Composable () -> Unit = {
Column(Modifier.fillMaxSize()) {
Column(Modifier.background(if (agenda) weekTint(week) else MaterialTheme.colorScheme.page)) {
TopAppBar( TopAppBar(
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent), colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent),
navigationIcon = { MenuButton(nav) }, navigationIcon = { MenuButton(nav) },
@@ -158,27 +170,83 @@ fun PlansScreen(vm: MainViewModel, nav: AppNav) {
actions = { actions = {
if (agenda && date != today()) IconButton(onClick = { if (agenda && date != today()) IconButton(onClick = {
date = today() date = today()
scope.launch { list.scrollToItem(0) } scope.launch { agendaList.scrollToItem(0) }
}) { Icon(Icons.Rounded.Today, contentDescription = "Сегодня") } }) { Icon(Icons.Rounded.Today, contentDescription = "Сегодня") }
IconButton(onClick = { tuning = true }) { Icon(Icons.Rounded.Tune, contentDescription = "Вид планов") } IconButton(onClick = { tuning = true }) { Icon(Icons.Rounded.Tune, contentDescription = "Вид планов") }
}, },
) )
if (agenda) SearchWeekStrip(timetable, date) { d -> }
val strip: @Composable (Boolean) -> Unit = { tinted ->
SearchWeekStrip(timetable, date, tinted = tinted) { d ->
date = d date = d
val index = rows.indexOfFirst { it is Row.Header && it.date == d } val index = agendaRows.indexOfFirst { it is Row.Header && it.date == d }
if (index >= 0) scope.launch { list.scrollToItem(index) } if (index >= 0) scope.launch { agendaList.scrollToItem(index) }
} }
} }
when (prefs.layout) { val planList: @Composable (PlansLayout, Boolean, Color) -> Unit = { layout, follow, color ->
PlansLayout.Board -> Board(days, byDay, overdue, lessonsOf, vm::toggleNote, onEdit = { editing = it }) val list = if (layout == PlansLayout.Agenda) agendaList else plainList
else -> { val rows = if (layout == PlansLayout.Agenda) agendaRows else plainRows
// The strip follows the list: the day at the top of the list is the selected one. // The selected day follows the list: the day at its top is the one shown elsewhere.
if (agenda) LaunchedEffect(rows) { if (follow) LaunchedEffect(rows, list) {
snapshotFlow { list.firstVisibleItemIndex }.collect { i -> snapshotFlow { list.firstVisibleItemIndex }.collect { i ->
rows.getOrNull(i)?.date?.let { if (it != date) date = it } rows.getOrNull(i)?.date?.let { if (it != date) date = it }
} }
} }
PlanList(list, rows, vm::toggleNote, onEdit = { editing = it }) PlanList(list, rows, vm::toggleNote, onEdit = { editing = it }, color = color)
}
BoxWithConstraints(Modifier.fillMaxSize()) {
val wide = maxWidth >= TWO_PANE
// 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)) {
appBar()
// The phone's week strip folds away for the list and the board.
if (!wide) AnimatedVisibility(
visible = agenda,
enter = expandVertically(MaterialTheme.motionScheme.defaultSpatialSpec()) + fadeIn(MaterialTheme.motionScheme.defaultEffectsSpec()),
exit = shrinkVertically(MaterialTheme.motionScheme.defaultSpatialSpec()) + fadeOut(MaterialTheme.motionScheme.fastEffectsSpec()),
) { strip(true) }
}
// Switching the layout fades through, as between sibling destinations.
AnimatedContent(
targetState = prefs.layout,
transitionSpec = { materialFadeThrough() },
label = "plans-layout",
) { layout ->
when {
layout == PlansLayout.Board ->
Board(days, byDay, overdue, lessonsOf, vm::toggleNote, onEdit = { editing = it }, wide = wide)
// Tablets: the plans on the left, the day at the list's top on the right — its
// lessons and plans together, as School Planner's agenda keeps a detail pane.
wide -> Row(Modifier.fillMaxSize().padding(end = 16.dp)) {
// The week strip stays on the calm page; the list below sits on a rounded card.
Column(Modifier.width(440.dp).fillMaxHeight().padding(start = 16.dp)) {
if (layout == PlansLayout.Agenda) strip(false)
Box(
Modifier
.fillMaxSize()
.padding(top = 8.dp)
.clip(MaterialTheme.shapes.extraLarge.copy(bottomStart = CornerSize(0.dp), bottomEnd = CornerSize(0.dp))),
) { planList(layout, true, MaterialTheme.colorScheme.card) }
}
Spacer(Modifier.width(16.dp))
DetailPane(Modifier.weight(1f)) {
DayAgenda(
vm, timetable, settings?.subgroup, date, all,
onLesson = { details = it to date },
onNote = { editing = it },
modifier = Modifier.fillMaxSize().padding(top = 12.dp),
)
}
}
else -> planList(layout, layout == PlansLayout.Agenda, MaterialTheme.colorScheme.surface)
} }
} }
} }
@@ -188,6 +256,11 @@ fun PlansScreen(vm: MainViewModel, nav: AppNav) {
) { Icon(Icons.Rounded.Add, contentDescription = "Новое задание") } ) { Icon(Icons.Rounded.Add, contentDescription = "Новое задание") }
} }
details?.let { (lesson, d) ->
ModalBottomSheet(onDismissRequest = { details = null }) {
LessonDetails(lesson, timetable?.bells.orEmpty(), d, vm, onClose = { details = null })
}
}
if (tuning) ModalBottomSheet(onDismissRequest = { tuning = false }) { PlansSettings(prefs, vm::setPlans) } if (tuning) ModalBottomSheet(onDismissRequest = { tuning = false }) { PlansSettings(prefs, vm::setPlans) }
val group = vm.currentGroupKey() val group = vm.currentGroupKey()
if (adding) ModalBottomSheet(onDismissRequest = { adding = false }) { if (adding) ModalBottomSheet(onDismissRequest = { adding = false }) {
@@ -232,25 +305,43 @@ private fun rows(
} }
@Composable @Composable
private fun PlanList(list: LazyListState, rows: List<Row>, onToggle: (String) -> Unit, onEdit: (Note) -> Unit) { private fun PlanList(
list: LazyListState,
rows: List<Row>,
onToggle: (String) -> Unit,
onEdit: (Note) -> Unit,
color: Color = MaterialTheme.colorScheme.surface,
) {
if (rows.isEmpty()) { if (rows.isEmpty()) {
CenteredEmpty(Modifier.background(color)) {
Empty("Планов нет", "Задания и заметки к парам появятся здесь. Добавьте первое кнопкой «+».", icon = Icons.Rounded.Checklist) Empty("Планов нет", "Задания и заметки к парам появятся здесь. Добавьте первое кнопкой «+».", icon = Icons.Rounded.Checklist)
}
return return
} }
LazyColumn( LazyColumn(
state = list, state = list,
contentPadding = PaddingValues(bottom = 96.dp + navBarPadding()), contentPadding = PaddingValues(bottom = 96.dp + navBarPadding()),
modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface), modifier = Modifier.fillMaxSize().background(color),
) { ) {
rows.forEachIndexed { i, row -> // Keys follow the day and the note, not the position, so ticking a task off, adding or
// deleting one moves the rows around smoothly instead of redrawing them in place.
rows.forEach { row ->
val day = row.date?.toString() ?: "overdue"
when (row) { when (row) {
is Row.Header -> stickyHeader(key = "h$i") { DayHeader(row) } is Row.Header -> stickyHeader(key = "h$day") { Box(Modifier.animateItem()) { DayHeader(row, color) } }
is Row.Lessons -> item(key = "l$i") { LessonsSummary(row.slots, Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) } is Row.Lessons -> item(key = "l$day") {
LessonsSummary(row.slots, Modifier.animateItem().padding(horizontal = 16.dp, vertical = 4.dp))
}
is Row.Item -> item(key = "n${row.note.id}") { is Row.Item -> item(key = "n${row.note.id}") {
Box(Modifier.animateItem()) {
NoteRow(row.note, onToggle = { onToggle(row.note.id) }, onClick = { onEdit(row.note) }, showSubject = true, showDue = row.date == null) NoteRow(row.note, onToggle = { onToggle(row.note.id) }, onClick = { onEdit(row.note) }, showSubject = true, showDue = row.date == null)
} }
is Row.Divider -> item(key = "d$i") { }
HorizontalDivider(Modifier.padding(horizontal = 20.dp), color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) is Row.Divider -> item(key = "d$day") {
HorizontalDivider(
Modifier.animateItem().padding(horizontal = 20.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
)
} }
} }
} }
@@ -259,7 +350,7 @@ private fun PlanList(list: LazyListState, rows: List<Row>, onToggle: (String) ->
/** A sticky day header: opaque, so the list scrolls under it. */ /** A sticky day header: opaque, so the list scrolls under it. */
@Composable @Composable
private fun DayHeader(h: Row.Header) { private fun DayHeader(h: Row.Header, color: Color) {
Text( Text(
h.title, h.title,
style = MaterialTheme.typography.titleSmallEmphasized, style = MaterialTheme.typography.titleSmallEmphasized,
@@ -270,7 +361,7 @@ private fun DayHeader(h: Row.Header) {
}, },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.background(MaterialTheme.colorScheme.surface) .background(color)
.padding(start = 20.dp, end = 20.dp, top = 14.dp, bottom = 10.dp) .padding(start = 20.dp, end = 20.dp, top = 14.dp, bottom = 10.dp)
.semantics { heading() }, .semantics { heading() },
) )
@@ -309,6 +400,7 @@ private fun Board(
lessonsOf: (LocalDate) -> List<Slot>, lessonsOf: (LocalDate) -> List<Slot>,
onToggle: (String) -> Unit, onToggle: (String) -> Unit,
onEdit: (Note) -> Unit, onEdit: (Note) -> Unit,
wide: Boolean = false,
) { ) {
val columns: List<LocalDate?> = (if (overdue.isNotEmpty()) listOf<LocalDate?>(null) else emptyList()) + days val columns: List<LocalDate?> = (if (overdue.isNotEmpty()) listOf<LocalDate?>(null) else emptyList()) + days
val pager: PagerState = rememberPagerState { columns.size } val pager: PagerState = rememberPagerState { columns.size }
@@ -316,6 +408,8 @@ private fun Board(
HorizontalPager( HorizontalPager(
state = pager, state = pager,
contentPadding = PaddingValues(start = 16.dp, end = 48.dp), contentPadding = PaddingValues(start = 16.dp, end = 48.dp),
// On a tablet several day columns stand side by side, like School Planner's board.
pageSize = if (wide) PageSize.Fixed(340.dp) else PageSize.Fill,
pageSpacing = 12.dp, pageSpacing = 12.dp,
verticalAlignment = Alignment.Top, verticalAlignment = Alignment.Top,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),

View File

@@ -1,13 +1,24 @@
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
package ritm.schedule.ui package org.xtimms.psu.schedule.ui
import androidx.compose.ui.unit.Dp
import androidx.compose.animation.togetherWith
import androidx.compose.animation.Crossfade
import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.fadeOut
import androidx.compose.animation.fadeIn
import androidx.compose.animation.expandVertically
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.AnimatedContent
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.foundation.layout.heightIn
import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.Check
import androidx.compose.foundation.interaction.collectIsDraggedAsState import androidx.compose.foundation.interaction.collectIsDraggedAsState
import androidx.compose.material.icons.rounded.VisibilityOff import androidx.compose.material.icons.rounded.VisibilityOff
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import ritm.schedule.data.hideKey import org.xtimms.psu.schedule.data.hideKey
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import kotlin.math.abs import kotlin.math.abs
@@ -24,16 +35,14 @@ import androidx.compose.material.icons.rounded.Today
import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableLongStateOf
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.util.Locale import java.util.Locale
import ritm.schedule.ui.theme.card import org.xtimms.psu.schedule.ui.theme.card
import ritm.schedule.ui.theme.page import org.xtimms.psu.schedule.ui.theme.page
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.FlowRow
@@ -50,7 +59,6 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.CloudOff import androidx.compose.material.icons.rounded.CloudOff
import androidx.compose.material.icons.rounded.ErrorOutline import androidx.compose.material.icons.rounded.ErrorOutline
@@ -61,12 +69,7 @@ import androidx.compose.material.icons.rounded.LocationOn
import androidx.compose.material.icons.rounded.Person import androidx.compose.material.icons.rounded.Person
import androidx.compose.material.icons.rounded.Refresh import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material.icons.rounded.CalendarViewWeek import androidx.compose.material.icons.rounded.CalendarViewWeek
import androidx.compose.material.icons.rounded.MoreVert
import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material.icons.rounded.SwapHoriz
import androidx.compose.material.icons.rounded.ViewDay import androidx.compose.material.icons.rounded.ViewDay
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.material.icons.rounded.Schedule import androidx.compose.material.icons.rounded.Schedule
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@@ -90,23 +93,17 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import ritm.schedule.data.Bell import org.xtimms.psu.schedule.data.Bell
import ritm.schedule.data.Lesson import org.xtimms.psu.schedule.data.Lesson
import ritm.schedule.data.Timetable import org.xtimms.psu.schedule.data.Timetable
import ritm.schedule.data.dayName import org.xtimms.psu.schedule.data.dayName
import ritm.schedule.data.longLabel import org.xtimms.psu.schedule.data.minutesLabel
import ritm.schedule.data.minutesLabel import org.xtimms.psu.schedule.data.monday
import ritm.schedule.data.monday import org.xtimms.psu.schedule.data.parseDate
import ritm.schedule.data.nowTime import org.xtimms.psu.schedule.data.today
import ritm.schedule.data.parseDate
import ritm.schedule.data.parseTime
import ritm.schedule.data.today
import ritm.schedule.data.weekdayName
import java.time.Duration import java.time.Duration
import java.time.LocalDate import java.time.LocalDate
@@ -136,15 +133,18 @@ fun ScheduleScreen(
title = { Text(t?.group ?: settings?.current?.group ?: "Расписание", style = MaterialTheme.typography.titleLargeEmphasized) }, title = { Text(t?.group ?: settings?.current?.group ?: "Расписание", style = MaterialTheme.typography.titleLargeEmphasized) },
actions = { ScheduleMenu(vm, nav) }, actions = { ScheduleMenu(vm, nav) },
) )
status.message?.let { Banner(it, offline = status.offline, onClose = vm::dismissMessage) } status.message?.let { Banner(it, offline = status.offline, onClose = vm::dismissMessage, onRetry = if (status.offline) vm::retry else null) }
when { when {
t == null && status.loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { LoadingIndicator() } t == null && status.loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { LoadingIndicator() }
t == null -> Empty( t == null -> CenteredEmpty {
Empty(
"Расписание не загружено", "Расписание не загружено",
"Проверьте подключение или выберите группу заново.", "Проверьте подключение или выберите группу заново.",
icon = Icons.Rounded.CloudOff, icon = Icons.Rounded.CloudOff,
action = "Выбрать группу" to nav.openGroups, // A chosen group that failed to load gets another try; otherwise pick one.
action = if (settings?.current != null) "Повторить" to { vm.refresh(); Unit } else "Выбрать группу" to nav.openGroups,
) )
}
else -> WordRows(t, subgroup, settings?.hidden.orEmpty(), onOpen = { details = it to null }) else -> WordRows(t, subgroup, settings?.hidden.orEmpty(), onOpen = { details = it to null })
} }
} }
@@ -255,8 +255,12 @@ private fun Planner(
val onToday = if (weekView) grid == today().monday() else date == today() val onToday = if (weekView) grid == today().monday() else date == today()
val doc = t.document val doc = t.document
Column(Modifier.fillMaxSize()) { // Tablets keep the calm page behind the header and put the timetable on a card, as School
Column(Modifier.background(tint)) { // Planner does; the week's colour on the header stays a phone touch.
BoxWithConstraints(Modifier.fillMaxSize()) {
val wide = maxWidth >= TWO_PANE
Column(Modifier.fillMaxSize().background(if (wide) MaterialTheme.colorScheme.page else Color.Transparent)) {
Column(Modifier.background(if (wide) MaterialTheme.colorScheme.page else tint)) {
TopAppBar( TopAppBar(
colors = TopAppBarDefaults.topAppBarColors( colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent, containerColor = Color.Transparent,
@@ -288,17 +292,24 @@ private fun Planner(
if (!weekView) gridMonday = date.monday().toEpochDay() if (!weekView) gridMonday = date.monday().toEpochDay()
onWeekView(!weekView) onWeekView(!weekView)
}) { }) {
Crossfade(weekView, animationSpec = MaterialTheme.motionScheme.fastEffectsSpec(), label = "view-icon") { week ->
Icon( Icon(
if (weekView) Icons.Rounded.ViewDay else Icons.Rounded.CalendarViewWeek, if (week) Icons.Rounded.ViewDay else Icons.Rounded.CalendarViewWeek,
contentDescription = if (weekView) "Показать день" else "Показать неделю", contentDescription = if (week) "Показать день" else "Показать неделю",
) )
} }
}
ScheduleMenu(vm, nav) ScheduleMenu(vm, nav)
}, },
) )
if (!weekView) HorizontalPager(state = strip, beyondViewportPageCount = 1) { page -> // The day strip folds away for the week grid, which has its own day headers.
AnimatedVisibility(
visible = !weekView,
enter = expandVertically(MaterialTheme.motionScheme.defaultSpatialSpec()) + fadeIn(MaterialTheme.motionScheme.defaultEffectsSpec()),
exit = shrinkVertically(MaterialTheme.motionScheme.defaultSpatialSpec()) + fadeOut(MaterialTheme.motionScheme.fastEffectsSpec()),
) { HorizontalPager(state = strip, beyondViewportPageCount = 1) { page ->
val monday = mondays[page] val monday = mondays[page]
Box(Modifier.fillMaxWidth().background(weekTintOf(weekOf(monday))), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxWidth().background(if (wide) Color.Transparent else weekTintOf(weekOf(monday))), contentAlignment = Alignment.Center) {
DayStrip( DayStrip(
monday = monday, monday = monday,
selected = date, selected = date,
@@ -309,13 +320,28 @@ private fun Planner(
modifier = Modifier.widthIn(max = MAX_CONTENT).padding(horizontal = 8.dp), modifier = Modifier.widthIn(max = MAX_CONTENT).padding(horizontal = 8.dp),
) )
} }
} } }
} }
status.message?.let { status.message?.let {
Box(Modifier.background(MaterialTheme.colorScheme.page)) { Banner(it, offline = status.offline, onClose = vm::dismissMessage) } Box(Modifier.background(MaterialTheme.colorScheme.page)) { Banner(it, offline = status.offline, onClose = vm::dismissMessage, onRetry = if (status.offline) vm::retry else null) }
} }
Surface(color = MaterialTheme.colorScheme.surface, modifier = Modifier.fillMaxSize()) { Surface(
if (weekView) { color = if (wide) MaterialTheme.colorScheme.card else MaterialTheme.colorScheme.surface,
shape = if (wide) MaterialTheme.shapes.extraLarge else RectangleShape,
modifier = Modifier
.fillMaxSize()
.then(if (wide) Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 16.dp + navBarPadding()) else Modifier),
) {
// Day and week views cross-fade without scaling: the grid's tinted day row must meet
// the header from the first frame, or the page shows through as a dark band.
val motion = MaterialTheme.motionScheme
AnimatedContent(
targetState = weekView,
// No size transform: both views fill the area, which itself grows as the strip folds.
transitionSpec = { (fadeIn(motion.defaultEffectsSpec()) togetherWith fadeOut(motion.fastEffectsSpec())).using(null) },
label = "schedule-view",
) { showWeek ->
if (showWeek) {
// The week grid pages through weeks under the finger as well. // The week grid pages through weeks under the finger as well.
val weeks = rememberPagerState(initialPage = mondays.indexOf(grid).coerceAtLeast(0)) { mondays.size } val weeks = rememberPagerState(initialPage = mondays.indexOf(grid).coerceAtLeast(0)) { mondays.size }
LaunchedEffect(gridMonday) { LaunchedEffect(gridMonday) {
@@ -331,7 +357,9 @@ private fun Planner(
vm, t, subgroup, monday, vm, t, subgroup, monday,
onOpen = onOpen, onOpen = onOpen,
onWeek = ::week, onWeek = ::week,
headerColor = weekTintOf(weekOf(monday)), 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,
) )
} }
} else PullToRefreshBox(isRefreshing = status.loading, onRefresh = { vm.refresh() }) { } else PullToRefreshBox(isRefreshing = status.loading, onRefresh = { vm.refresh() }) {
@@ -347,6 +375,8 @@ private fun Planner(
} }
} }
} }
}
}
@Composable @Composable
private fun DayPage(date: LocalDate, slots: List<Slot>, onOpen: (Lesson, LocalDate) -> Unit) { private fun DayPage(date: LocalDate, slots: List<Slot>, onOpen: (Lesson, LocalDate) -> Unit) {
@@ -360,8 +390,10 @@ private fun DayPage(date: LocalDate, slots: List<Slot>, onOpen: (Lesson, LocalDa
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) { ) {
if (slots.isEmpty()) item { if (slots.isEmpty()) item {
Box(Modifier.fillParentMaxHeight(0.85f).fillMaxWidth(), contentAlignment = Alignment.Center) {
Empty("Занятий нет", "По таблице недель в этот день свободно — можно отдохнуть.", icon = Icons.Rounded.Weekend) Empty("Занятий нет", "По таблице недель в этот день свободно — можно отдохнуть.", icon = Icons.Rounded.Weekend)
} }
}
slots.forEach { slot -> slots.forEach { slot ->
val running = isToday && !clock.isBefore(slot.start) && clock.isBefore(slot.end) val running = isToday && !clock.isBefore(slot.start) && clock.isBefore(slot.end)
val status = when { val status = when {
@@ -371,6 +403,8 @@ private fun DayPage(date: LocalDate, slots: List<Slot>, onOpen: (Lesson, LocalDa
else -> null else -> null
} }
items(slot.lessons, key = { "${slot.start}|${it.key}" }) { lesson -> items(slot.lessons, key = { "${slot.start}|${it.key}" }) { lesson ->
// Eases into place when a lesson is hidden or a note changes the card's height.
AnimatedItem {
LessonCard( LessonCard(
lesson, lesson,
time = "${slot.start} → ${slot.end}", time = "${slot.start} → ${slot.end}",
@@ -385,6 +419,7 @@ private fun DayPage(date: LocalDate, slots: List<Slot>, onOpen: (Lesson, LocalDa
} }
} }
} }
}
/** A lesson as a soft card in its subject's colour, like School Planner's day list. */ /** A lesson as a soft card in its subject's colour, like School Planner's day list. */
@OptIn(ExperimentalLayoutApi::class) @OptIn(ExperimentalLayoutApi::class)
@@ -615,7 +650,7 @@ private fun WordRows(t: Timetable, subgroup: String?, hidden: Set<String>, onOpe
} }
@Composable @Composable
fun Banner(text: String, offline: Boolean, onClose: (() -> Unit)?) { fun Banner(text: String, offline: Boolean, onClose: (() -> Unit)?, onRetry: (() -> Unit)? = null) {
Surface( Surface(
shape = MaterialTheme.shapes.large, shape = MaterialTheme.shapes.large,
color = if (offline) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.errorContainer, color = if (offline) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.errorContainer,
@@ -626,11 +661,33 @@ fun Banner(text: String, offline: Boolean, onClose: (() -> Unit)?) {
Icon(if (offline) Icons.Rounded.CloudOff else Icons.Rounded.ErrorOutline, contentDescription = null) Icon(if (offline) Icons.Rounded.CloudOff else Icons.Rounded.ErrorOutline, contentDescription = null)
Spacer(Modifier.width(12.dp)) Spacer(Modifier.width(12.dp))
Text(text, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f).padding(vertical = 8.dp)) Text(text, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f).padding(vertical = 8.dp))
if (onRetry != null) TextButton(onClick = onRetry) { Text("Повторить") }
if (onClose != null) TextButton(onClick = onClose) { Text("OK") } if (onClose != null) TextButton(onClick = onClose) { Text("OK") }
} }
} }
} }
/**
* Centres an empty state in the space it is given — a phone screen or a tablet's pane —
* and scrolls when that space is too low, so the illustration is never squeezed.
*/
@Composable
fun CenteredEmpty(
modifier: Modifier = Modifier,
/** Room kept clear inside the scroll (under a FAB): content scrolls past it, never gets cut. */
bottomInset: Dp = 0.dp,
content: @Composable () -> Unit,
) {
BoxWithConstraints(modifier.fillMaxSize()) {
val height = maxHeight
Column(
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).heightIn(min = height).padding(bottom = bottomInset),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) { content() }
}
}
/** /**
* Empty state in the RideBus style: an icon on a seven-sided cookie shape, a title, * Empty state in the RideBus style: an icon on a seven-sided cookie shape, a title,
* a calm explanation and an optional tonal action. * a calm explanation and an optional tonal action.

View File

@@ -1,7 +1,12 @@
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
package ritm.schedule.ui package org.xtimms.psu.schedule.ui
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.interaction.collectIsDraggedAsState import androidx.compose.foundation.interaction.collectIsDraggedAsState
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background import androidx.compose.foundation.background
@@ -10,9 +15,7 @@ import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.rounded.PersonSearch import androidx.compose.material.icons.rounded.PersonSearch
import androidx.compose.material.icons.rounded.Schedule
import androidx.compose.material.icons.rounded.Today import androidx.compose.material.icons.rounded.Today
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@@ -20,8 +23,7 @@ import androidx.compose.ui.text.style.TextOverflow
import java.time.temporal.ChronoUnit import java.time.temporal.ChronoUnit
import androidx.compose.material.icons.rounded.SearchOff import androidx.compose.material.icons.rounded.SearchOff
import androidx.compose.material.icons.rounded.EventAvailable import androidx.compose.material.icons.rounded.EventAvailable
import ritm.schedule.ui.theme.card import org.xtimms.psu.schedule.ui.theme.page
import ritm.schedule.ui.theme.page
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.ExperimentalLayoutApi
@@ -36,9 +38,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.activity.compose.PredictiveBackHandler
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@@ -47,44 +47,34 @@ import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.MeetingRoom import androidx.compose.material.icons.rounded.MeetingRoom
import androidx.compose.material.icons.rounded.Person import androidx.compose.material.icons.rounded.Person
import androidx.compose.material.icons.rounded.Search
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import ritm.schedule.data.Bell import org.xtimms.psu.schedule.data.attempt
import ritm.schedule.data.attempt import org.xtimms.psu.schedule.data.Coverage
import ritm.schedule.data.Coverage import org.xtimms.psu.schedule.data.DirectoryDay
import ritm.schedule.data.DirectoryDay import org.xtimms.psu.schedule.data.DirectorySearch
import ritm.schedule.data.DirectorySearch import org.xtimms.psu.schedule.data.Lesson
import ritm.schedule.data.Lesson import org.xtimms.psu.schedule.data.monday
import ritm.schedule.data.longLabel import org.xtimms.psu.schedule.data.today
import ritm.schedule.data.monday
import ritm.schedule.data.nowTime
import ritm.schedule.data.parseTime
import ritm.schedule.data.today
import ritm.schedule.data.weekdayName
import java.time.LocalDate import java.time.LocalDate
private sealed interface Target { private sealed interface Target {
@@ -110,12 +100,14 @@ fun SearchScreen(vm: MainViewModel, nav: AppNav) {
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
if (bells.isEmpty()) attempt { vm.api.calendar() }.onSuccess { bells = it.bells } if (bells.isEmpty()) attempt { vm.api.calendar() }.onSuccess { bells = it.bells }
} }
LaunchedEffect(query) { // Bumped by "Повторить" to run the failed search or day request again.
var retries by remember { mutableIntStateOf(0) }
LaunchedEffect(query, retries) {
if (query.trim().length < 2) { hits = null; return@LaunchedEffect } if (query.trim().length < 2) { hits = null; return@LaunchedEffect }
delay(250) delay(250)
attempt { vm.api.searchDirectory(query.trim()) }.onSuccess { hits = it; error = null }.onFailure { error = it.message } attempt { vm.api.searchDirectory(query.trim()) }.onSuccess { hits = it; error = null }.onFailure { error = it.message }
} }
LaunchedEffect(target, date) { LaunchedEffect(target, date, retries) {
val t = target ?: return@LaunchedEffect val t = target ?: return@LaunchedEffect
loading = true loading = true
attempt { attempt {
@@ -128,20 +120,30 @@ fun SearchScreen(vm: MainViewModel, nav: AppNav) {
} }
// Back from a teacher's or room's day returns to the search results. // Back from a teacher's or room's day returns to the search results.
BackHandler(enabled = target != null) { target = null; day = null } // Predictive back on a phone: the day scales down and slides with the finger, showing
// that back returns to the results; a cancelled swipe puts it back in place.
var backProgress by remember { mutableFloatStateOf(0f) }
PredictiveBackHandler(enabled = target != null) { events ->
try {
events.collect { backProgress = it.progress }
target = null
day = null
} finally {
backProgress = 0f
}
}
val scroll = TopAppBarDefaults.pinnedScrollBehavior() val scroll = TopAppBarDefaults.pinnedScrollBehavior()
val dated = target != null val dated = target != null
val week = timetable?.days?.firstOrNull { it.date == date.monday().toString() }?.week val week = timetable?.days?.firstOrNull { it.date == date.monday().toString() }?.week
// Undated search tints its header once results scroll under it; dated views carry the week's colour. // Undated search tints its header once results scroll under it; dated views carry the week's colour.
val scrolledTint by animateColorAsState( val scrolledTint by animateColorAsState(
if (scroll.state.overlappedFraction > 0.01f) MaterialTheme.colorScheme.surfaceContainerHigh else MaterialTheme.colorScheme.page, if (scroll.state.overlappedFraction > 0.01f && !isTablet()) MaterialTheme.colorScheme.surfaceContainerHigh else MaterialTheme.colorScheme.page,
MaterialTheme.motionScheme.defaultEffectsSpec(), MaterialTheme.motionScheme.defaultEffectsSpec(),
label = "search-header", label = "search-header",
) )
val weekColor = weekTint(week) val weekColor = weekTint(week)
Column(Modifier.fillMaxSize().nestedScroll(scroll.nestedScrollConnection)) { val appBar: @Composable () -> Unit = {
Column(Modifier.background(if (dated) weekColor else scrolledTint)) {
TopAppBar( TopAppBar(
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent, scrolledContainerColor = Color.Transparent), colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent, scrolledContainerColor = Color.Transparent),
scrollBehavior = scroll, scrollBehavior = scroll,
@@ -181,27 +183,26 @@ fun SearchScreen(vm: MainViewModel, nav: AppNav) {
} }
}, },
) )
if (dated) SearchWeekStrip(timetable, date) { date = it }
} }
Box(Modifier.fillMaxSize().background(if (target != null) MaterialTheme.colorScheme.surface else MaterialTheme.colorScheme.page)) { // [pane]: beside a detail pane the results stay while a day is open, and the idle
ContentColumn { // illustration stays out, since the pane already explains what to do.
val results: @Composable (Modifier, Boolean) -> Unit = { modifier, pane ->
LazyColumn( LazyColumn(
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 24.dp + navBarPadding()), contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 24.dp + navBarPadding()),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxSize(), modifier = modifier,
) { ) {
error?.let { item { Banner(it, offline = true, onClose = null) } } error?.let { item { Banner(it, offline = true, onClose = null, onRetry = { error = null; retries++ }) } }
if (target == null || pane) {
if (target == null) {
item { SearchField(query, { query = it }, "Фамилия или номер аудитории") } item { SearchField(query, { query = it }, "Фамилия или номер аудитории") }
val h = hits val h = hits
if (h == null) item { if (h == null && !pane) item {
Empty( Box(Modifier.fillParentMaxHeight(0.75f).fillMaxWidth(), contentAlignment = Alignment.Center) { Empty(
"Кто где", "Кто где",
"Найдите преподавателя или аудиторию — покажем занятия на выбранный день по всем расписаниям университета.", "Найдите преподавателя или аудиторию — покажем занятия на выбранный день по всем расписаниям университета.",
icon = Icons.Rounded.PersonSearch, icon = Icons.Rounded.PersonSearch,
) ) }
} else { } else if (h != null) {
if (h.teachers.isNotEmpty()) item { if (h.teachers.isNotEmpty()) item {
ListCard(Icons.Rounded.Person, "Преподаватели") { ListCard(Icons.Rounded.Person, "Преподаватели") {
h.teachers.forEachIndexed { index, t -> h.teachers.forEachIndexed { index, t ->
@@ -225,17 +226,31 @@ fun SearchScreen(vm: MainViewModel, nav: AppNav) {
} }
} }
if (h.teachers.isEmpty() && h.rooms.isEmpty()) item { if (h.teachers.isEmpty() && h.rooms.isEmpty()) item {
Box(Modifier.fillParentMaxHeight(0.75f).fillMaxWidth(), contentAlignment = Alignment.Center) {
Empty("Никого не нашли", "Ни преподавателя, ни аудитории с таким названием в расписаниях каталога.", icon = Icons.Rounded.SearchOff) Empty("Никого не нашли", "Ни преподавателя, ни аудитории с таким названием в расписаниях каталога.", icon = Icons.Rounded.SearchOff)
} }
} }
} }
}
}
}
val dayList: @Composable (Modifier) -> Unit = { modifier ->
LazyColumn(
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 24.dp + navBarPadding()),
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = modifier,
) {
if (target != null) { if (target != null) {
val d = day val d = day
if (loading && d == null) item { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { LoadingIndicator() } } if (loading && d == null) item { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { LoadingIndicator() } }
if (d != null) { if (d != null) {
if (d.lessons.isEmpty()) item { Empty("Занятий нет", "По расписаниям каталога в этот день здесь пусто.", icon = Icons.Rounded.EventAvailable) } if (d.lessons.isEmpty()) item {
items(d.lessons, key = { "${it.start}|${it.subject}|${it.rooms}" }) { l -> Box(Modifier.fillParentMaxHeight(0.8f).fillMaxWidth(), contentAlignment = Alignment.Center) {
Empty("Занятий нет", "По расписаниям каталога в этот день здесь пусто.", icon = Icons.Rounded.EventAvailable)
}
}
items(d.lessons, key = { "${it.start}|${it.subject}|${it.rooms}" }) { l -> AnimatedItem {
LessonCard( LessonCard(
Lesson( Lesson(
key = l.start + l.subject, key = l.start + l.subject,
@@ -255,7 +270,7 @@ fun SearchScreen(vm: MainViewModel, nav: AppNav) {
time = l.start + (l.end?.let { " → $it" } ?: ""), time = l.start + (l.end?.let { " → $it" } ?: ""),
pair = bells.firstOrNull { it.start == l.start }?.pair, pair = bells.firstOrNull { it.start == l.start }?.pair,
onClick = null, onClick = null,
) ) }
} }
item { CoverageNote(d.coverage) } item { CoverageNote(d.coverage) }
} }
@@ -263,13 +278,94 @@ fun SearchScreen(vm: MainViewModel, nav: AppNav) {
} }
} }
BoxWithConstraints(Modifier.fillMaxSize()) {
if (maxWidth >= TWO_PANE) Column(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.page)) {
// Tablets: the search on the left, the chosen teacher's or room's day on the right.
TopAppBar(
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent),
navigationIcon = { MenuButton(nav) },
title = { Text("Поиск", style = MaterialTheme.typography.titleLargeEmphasized) },
)
Row(Modifier.fillMaxSize().padding(end = 16.dp)) {
results(Modifier.width(440.dp).fillMaxHeight(), true)
Spacer(Modifier.width(16.dp))
DetailPane(Modifier.weight(1f)) {
val t = target
if (t == null) CenteredEmpty {
Empty(
"Чьё расписание открыть?",
"Выберите преподавателя или аудиторию слева — здесь появятся их занятия по дням.",
icon = Icons.Rounded.PersonSearch,
)
} else Column(Modifier.fillMaxSize()) {
// Plain card like the other panes; the week's colour is a phone touch.
Column {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(start = 20.dp, end = 8.dp, top = 12.dp, bottom = 4.dp)) {
Column(Modifier.weight(1f)) {
Heading(
when (t) {
is Target.Teacher -> t.name
is Target.Room -> "Аудитория ${t.room}"
},
style = MaterialTheme.typography.titleLargeEmphasized,
)
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
weekTitle(date.monday().plusDays(3), week?.n),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
week?.let {
Spacer(Modifier.width(10.dp))
WeekMark(it, short = true)
}
}
}
if (date != startDate()) IconButton(onClick = { date = startDate() }) {
Icon(Icons.Rounded.Today, contentDescription = "Сегодня")
}
IconButton(onClick = { target = null; day = null }) {
Icon(Icons.Rounded.Close, contentDescription = "Закрыть")
}
}
SearchWeekStrip(timetable, date, tinted = false) { date = it }
}
dayList(Modifier.fillMaxSize())
}
}
}
} else Column(Modifier.fillMaxSize().nestedScroll(scroll.nestedScrollConnection)) {
Column(Modifier.background(if (dated) weekColor else scrolledTint)) {
appBar()
if (dated) SearchWeekStrip(timetable, date) { date = it }
}
Box(Modifier.fillMaxSize().background(if (target != null) MaterialTheme.colorScheme.surface else MaterialTheme.colorScheme.page)) {
ContentColumn {
if (target == null) results(Modifier.fillMaxSize(), false)
else dayList(
Modifier.fillMaxSize().graphicsLayer {
val p = backProgress
scaleX = 1f - 0.1f * p
scaleY = 1f - 0.1f * p
translationX = 32.dp.toPx() * p
alpha = 1f - 0.4f * p
},
)
}
}
} }
} }
} }
/** The calm day strip as a pager of weeks around today; each page wears its week's colour. */ /** The calm day strip as a pager of weeks around today; each page wears its week's colour. */
@Composable @Composable
internal fun SearchWeekStrip(timetable: ritm.schedule.data.Timetable?, date: LocalDate, onDate: (LocalDate) -> Unit) { internal fun SearchWeekStrip(
timetable: org.xtimms.psu.schedule.data.Timetable?,
date: LocalDate,
tinted: Boolean = true,
onDate: (LocalDate) -> Unit,
) {
val base = remember { startDate().monday() } val base = remember { startDate().monday() }
fun mondayAt(page: Int): LocalDate = base.plusWeeks((page - SPAN).toLong()) fun mondayAt(page: Int): LocalDate = base.plusWeeks((page - SPAN).toLong())
val pager = rememberPagerState(initialPage = SPAN + ChronoUnit.WEEKS.between(base, date.monday()).toInt()) { SPAN * 2 + 1 } val pager = rememberPagerState(initialPage = SPAN + ChronoUnit.WEEKS.between(base, date.monday()).toInt()) { SPAN * 2 + 1 }
@@ -294,7 +390,9 @@ internal fun SearchWeekStrip(timetable: ritm.schedule.data.Timetable?, date: Loc
HorizontalPager(state = pager, beyondViewportPageCount = 1) { page -> HorizontalPager(state = pager, beyondViewportPageCount = 1) { page ->
val monday = mondayAt(page) val monday = mondayAt(page)
Box( Box(
Modifier.fillMaxWidth().background(weekTintOf(timetable?.days?.firstOrNull { it.date == monday.toString() }?.week)), Modifier.fillMaxWidth().background(
if (tinted) weekTintOf(timetable?.days?.firstOrNull { it.date == monday.toString() }?.week) else Color.Transparent,
),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
DayStrip( DayStrip(

View File

@@ -1,6 +1,6 @@
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) @file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
package ritm.schedule.ui package org.xtimms.psu.schedule.ui
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -31,15 +31,15 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import ritm.schedule.data.Exam import org.xtimms.psu.schedule.data.Exam
import ritm.schedule.data.Timetable import org.xtimms.psu.schedule.data.Timetable
import ritm.schedule.data.longLabel import org.xtimms.psu.schedule.data.longLabel
import ritm.schedule.data.parseDate import org.xtimms.psu.schedule.data.parseDate
import ritm.schedule.data.plural import org.xtimms.psu.schedule.data.plural
import ritm.schedule.data.shortWeekday import org.xtimms.psu.schedule.data.shortWeekday
import ritm.schedule.data.today import org.xtimms.psu.schedule.data.today
import ritm.schedule.data.weekdayName import org.xtimms.psu.schedule.data.weekdayName
import ritm.schedule.ui.theme.card import org.xtimms.psu.schedule.ui.theme.card
import java.time.LocalDate import java.time.LocalDate
import java.time.temporal.ChronoUnit import java.time.temporal.ChronoUnit

View File

@@ -1,12 +1,11 @@
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
package ritm.schedule.ui package org.xtimms.psu.schedule.ui
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.selection.toggleable
import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.Role
import ritm.schedule.ui.theme.card import org.xtimms.psu.schedule.ui.theme.card
import ritm.schedule.ui.theme.page
import android.Manifest import android.Manifest
import android.os.Build import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
@@ -18,7 +17,6 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@@ -38,12 +36,11 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import ritm.schedule.BuildConfig import org.xtimms.psu.schedule.BuildConfig
import ritm.schedule.data.ThemeMode import org.xtimms.psu.schedule.data.ThemeMode
import ritm.schedule.notify.Reminders import org.xtimms.psu.schedule.notify.Reminders
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable

View File

@@ -1,15 +1,15 @@
package ritm.schedule.ui package org.xtimms.psu.schedule.ui
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.lerp import androidx.compose.ui.graphics.lerp
import ritm.schedule.data.Display import org.xtimms.psu.schedule.data.Display
import ritm.schedule.ui.theme.harmonizeWith import org.xtimms.psu.schedule.ui.theme.harmonizeWith
import ritm.schedule.data.Lesson import org.xtimms.psu.schedule.data.Lesson
import ritm.schedule.data.Timetable import org.xtimms.psu.schedule.data.Timetable
import ritm.schedule.data.parseTime import org.xtimms.psu.schedule.data.parseTime
import java.time.LocalTime import java.time.LocalTime
/** /**

View File

@@ -1,18 +1,22 @@
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
package ritm.schedule.ui package org.xtimms.psu.schedule.ui
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.staggeredgrid.items
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid
import androidx.compose.material.icons.rounded.HowToReg import androidx.compose.material.icons.rounded.HowToReg
import androidx.compose.material.icons.automirrored.rounded.Assignment import androidx.compose.material.icons.automirrored.rounded.Assignment
import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.Add
import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FilledTonalButton
import ritm.schedule.data.Note import org.xtimms.psu.schedule.data.Note
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.material.icons.rounded.DateRange import androidx.compose.material.icons.rounded.DateRange
import androidx.compose.material.icons.rounded.Event import androidx.compose.material.icons.rounded.Event
import ritm.schedule.ui.theme.page import org.xtimms.psu.schedule.ui.theme.page
import ritm.schedule.ui.theme.card import org.xtimms.psu.schedule.ui.theme.card
import ritm.schedule.ui.theme.page
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -25,10 +29,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.BarChart import androidx.compose.material.icons.rounded.BarChart
@@ -36,7 +37,6 @@ import androidx.compose.material.icons.rounded.EventBusy
import androidx.compose.material.icons.rounded.LocationOn import androidx.compose.material.icons.rounded.LocationOn
import androidx.compose.material.icons.automirrored.rounded.MenuBook import androidx.compose.material.icons.automirrored.rounded.MenuBook
import androidx.compose.material.icons.rounded.Person import androidx.compose.material.icons.rounded.Person
import androidx.compose.material3.CircularWavyProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -62,15 +62,15 @@ import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import ritm.schedule.data.Lesson import org.xtimms.psu.schedule.data.Lesson
import ritm.schedule.data.Timetable import org.xtimms.psu.schedule.data.Timetable
import ritm.schedule.data.longLabel import org.xtimms.psu.schedule.data.longLabel
import ritm.schedule.data.monday import org.xtimms.psu.schedule.data.monday
import ritm.schedule.data.parseDate import org.xtimms.psu.schedule.data.parseDate
import ritm.schedule.data.plural import org.xtimms.psu.schedule.data.plural
import ritm.schedule.data.shortWeekday import org.xtimms.psu.schedule.data.shortWeekday
import ritm.schedule.data.today import org.xtimms.psu.schedule.data.today
import ritm.schedule.data.weekdayName import org.xtimms.psu.schedule.data.weekdayName
import java.time.LocalTime import java.time.LocalTime
/** Everything the loaded weeks say about one subject. */ /** Everything the loaded weeks say about one subject. */
@@ -128,24 +128,31 @@ fun SubjectsScreen(vm: MainViewModel, nav: AppNav) {
val t = timetable val t = timetable
when { when {
t == null && status.loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { LoadingIndicator() } t == null && status.loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { LoadingIndicator() }
t == null -> Empty("Предметов пока нет", "Выберите группу — предметы появятся из её расписания.", icon = Icons.AutoMirrored.Rounded.MenuBook, action = "Выбрать группу" to nav.openGroups) t == null -> CenteredEmpty {
t.semester == null -> Empty("Предметы недоступны", "Для этого расписания даты не рассчитаны: откройте вкладку «Расписание».", icon = Icons.Rounded.EventBusy) Empty("Предметов пока нет", "Выберите группу — предметы появятся из её расписания.", icon = Icons.AutoMirrored.Rounded.MenuBook, action = "Выбрать группу" to nav.openGroups)
else -> ContentColumn { }
t.semester == null -> CenteredEmpty {
Empty("Предметы недоступны", "Для этого расписания даты не рассчитаны: откройте вкладку «Расписание».", icon = Icons.Rounded.EventBusy)
}
// One column on a phone, as many as fit on a tablet (cards differ in height, so staggered).
else -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) {
val subgroup = settings?.subgroup val subgroup = settings?.subgroup
val subjects = vm.summaries(t, subgroup, clock) val subjects = vm.summaries(t, subgroup, clock)
LazyColumn( LazyVerticalStaggeredGrid(
columns = StaggeredGridCells.Adaptive(320.dp),
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 24.dp + navBarPadding()), contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 24.dp + navBarPadding()),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalItemSpacing = 12.dp,
modifier = Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.widthIn(max = 1200.dp).fillMaxSize(),
) { ) {
item { WeekLoad(vm, t, subgroup, subjects.size) } item(span = StaggeredGridItemSpan.FullLine) { WeekLoad(vm, t, subgroup, subjects.size) }
item { item(span = StaggeredGridItemSpan.FullLine) {
Row(verticalAlignment = Alignment.Bottom, modifier = Modifier.padding(top = 8.dp)) { Row(verticalAlignment = Alignment.Bottom, modifier = Modifier.padding(top = 8.dp)) {
Heading("Все предметы", style = MaterialTheme.typography.titleLargeEmphasized, modifier = Modifier.weight(1f)) Heading("Все предметы", style = MaterialTheme.typography.titleLargeEmphasized, modifier = Modifier.weight(1f))
Text("по ближайшему занятию", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) Text("по ближайшему занятию", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
} }
} }
items(subjects, key = { subjectKey(it.name) }) { s -> SubjectCard(s, onClick = { open = s }) } items(subjects, key = { subjectKey(it.name) }) { s -> AnimatedItem { SubjectCard(s, onClick = { open = s }) } }
} }
} }
} }

View File

@@ -1,17 +1,13 @@
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) @file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
package ritm.schedule.ui package org.xtimms.psu.schedule.ui
import ritm.schedule.ui.theme.card import androidx.compose.ui.text.intl.LocaleList
import ritm.schedule.ui.theme.page import androidx.compose.ui.text.style.Hyphens
import androidx.compose.ui.text.style.LineBreak
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.CustomAccessibilityAction import androidx.compose.ui.semantics.CustomAccessibilityAction
import androidx.compose.ui.semantics.customActions import androidx.compose.ui.semantics.customActions
import kotlin.math.abs
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
@@ -38,18 +34,17 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import ritm.schedule.data.Lesson import org.xtimms.psu.schedule.data.Lesson
import ritm.schedule.data.Timetable import org.xtimms.psu.schedule.data.Timetable
import ritm.schedule.data.longLabel import org.xtimms.psu.schedule.data.longLabel
import ritm.schedule.data.shortWeekday import org.xtimms.psu.schedule.data.shortWeekday
import ritm.schedule.data.today import org.xtimms.psu.schedule.data.today
import ritm.schedule.data.weekdayName import org.xtimms.psu.schedule.data.weekdayName
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalTime import java.time.LocalTime
@@ -202,7 +197,8 @@ private fun LessonBlock(lesson: Lesson, slot: Slot, wide: Boolean, notes: Int, o
Column(Modifier.padding(horizontal = 6.dp, vertical = 4.dp).clearAndSetSemantics {}, verticalArrangement = Arrangement.spacedBy(2.dp)) { Column(Modifier.padding(horizontal = 6.dp, vertical = 4.dp).clearAndSetSemantics {}, verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text( Text(
if (wide) lesson.subject else abbreviate(lesson.subject), if (wide) lesson.subject else abbreviate(lesson.subject),
style = MaterialTheme.typography.labelLargeEmphasized, // Long Russian words hyphenate by syllable instead of breaking mid-letter.
style = MaterialTheme.typography.labelLargeEmphasized.copy(hyphens = Hyphens.Auto, lineBreak = LineBreak.Paragraph, localeList = LocaleList("ru")),
maxLines = if (wide) 3 else 2, maxLines = if (wide) 3 else 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )

View File

@@ -1,4 +1,4 @@
package ritm.schedule.ui.theme package org.xtimms.psu.schedule.ui.theme
import android.os.Build import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
@@ -15,7 +15,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import com.google.android.material.color.MaterialColors import com.google.android.material.color.MaterialColors
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import ritm.schedule.data.ThemeMode import org.xtimms.psu.schedule.data.ThemeMode
// Material 3 tonal-spot scheme from a blue seed (#0061A4). // Material 3 tonal-spot scheme from a blue seed (#0061A4).
internal val Light = lightColorScheme( internal val Light = lightColorScheme(

View File

@@ -1,4 +1,4 @@
package ritm.schedule.widget package org.xtimms.psu.schedule.widget
import android.content.Context import android.content.Context
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -11,9 +11,8 @@ import androidx.glance.action.actionStartActivity
import androidx.glance.action.clickable import androidx.glance.action.clickable
import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver import androidx.glance.appwidget.GlanceAppWidgetReceiver
import ritm.schedule.app import org.xtimms.psu.schedule.app
import androidx.glance.appwidget.provideContent import androidx.glance.appwidget.provideContent
import androidx.glance.background
import androidx.glance.layout.Alignment import androidx.glance.layout.Alignment
import androidx.glance.layout.Column import androidx.glance.layout.Column
import androidx.glance.layout.Row import androidx.glance.layout.Row
@@ -24,7 +23,7 @@ import androidx.glance.layout.width
import androidx.glance.text.FontWeight import androidx.glance.text.FontWeight
import androidx.glance.text.Text import androidx.glance.text.Text
import androidx.glance.text.TextStyle import androidx.glance.text.TextStyle
import ritm.schedule.MainActivity import org.xtimms.psu.schedule.MainActivity
/** One line on the home screen: when the next lesson is, what it is and where. */ /** One line on the home screen: when the next lesson is, what it is and where. */
class NextLessonWidget : GlanceAppWidget() { class NextLessonWidget : GlanceAppWidget() {

View File

@@ -1,4 +1,4 @@
package ritm.schedule.widget package org.xtimms.psu.schedule.widget
import android.content.Context import android.content.Context
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -14,7 +14,6 @@ import androidx.glance.appwidget.GlanceAppWidgetReceiver
import androidx.glance.LocalSize import androidx.glance.LocalSize
import androidx.glance.appwidget.SizeMode import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.provideContent import androidx.glance.appwidget.provideContent
import androidx.glance.background
import androidx.glance.layout.Alignment import androidx.glance.layout.Alignment
import androidx.glance.layout.Column import androidx.glance.layout.Column
import androidx.glance.layout.Row import androidx.glance.layout.Row
@@ -26,15 +25,15 @@ import androidx.glance.layout.padding
import androidx.glance.text.FontWeight import androidx.glance.text.FontWeight
import androidx.glance.text.Text import androidx.glance.text.Text
import androidx.glance.text.TextStyle import androidx.glance.text.TextStyle
import ritm.schedule.MainActivity import org.xtimms.psu.schedule.MainActivity
import ritm.schedule.app import org.xtimms.psu.schedule.app
import ritm.schedule.data.Lesson import org.xtimms.psu.schedule.data.Lesson
import ritm.schedule.data.longLabel import org.xtimms.psu.schedule.data.longLabel
import ritm.schedule.data.minutesLabel import org.xtimms.psu.schedule.data.minutesLabel
import ritm.schedule.data.nowTime import org.xtimms.psu.schedule.data.nowTime
import ritm.schedule.data.parseTime import org.xtimms.psu.schedule.data.parseTime
import ritm.schedule.data.today import org.xtimms.psu.schedule.data.today
import ritm.schedule.data.weekdayName import org.xtimms.psu.schedule.data.weekdayName
import java.time.Duration import java.time.Duration
import java.time.LocalDate import java.time.LocalDate

View File

@@ -1,13 +1,13 @@
package ritm.schedule.widget package org.xtimms.psu.schedule.widget
import android.content.Context import android.content.Context
import ritm.schedule.app import org.xtimms.psu.schedule.app
import ritm.schedule.data.Lesson import org.xtimms.psu.schedule.data.Lesson
import ritm.schedule.data.minutesLabel import org.xtimms.psu.schedule.data.minutesLabel
import ritm.schedule.data.nowTime import org.xtimms.psu.schedule.data.nowTime
import ritm.schedule.data.parseTime import org.xtimms.psu.schedule.data.parseTime
import ritm.schedule.data.shortWeekday import org.xtimms.psu.schedule.data.shortWeekday
import ritm.schedule.data.today import org.xtimms.psu.schedule.data.today
import java.time.Duration import java.time.Duration
import java.time.LocalDate import java.time.LocalDate

View File

@@ -1,4 +1,4 @@
package ritm.schedule.widget package org.xtimms.psu.schedule.widget
import android.os.Build import android.os.Build
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -12,9 +12,9 @@ import androidx.glance.appwidget.cornerRadius
import androidx.glance.background import androidx.glance.background
import androidx.glance.material3.ColorProviders import androidx.glance.material3.ColorProviders
import androidx.glance.unit.ColorProvider import androidx.glance.unit.ColorProvider
import ritm.schedule.R import org.xtimms.psu.schedule.R
import ritm.schedule.ui.theme.Dark import org.xtimms.psu.schedule.ui.theme.Dark
import ritm.schedule.ui.theme.Light import org.xtimms.psu.schedule.ui.theme.Light
/** /**
* The app's colours on the home screen: wallpaper colours when the student chose them and * The app's colours on the home screen: wallpaper colours when the student chose them and

View File

@@ -1,7 +1,7 @@
package ritm.schedule.work package org.xtimms.psu.schedule.work
import ritm.schedule.tile.LessonTileService import org.xtimms.psu.schedule.tile.LessonTileService
import ritm.schedule.widget.NextLessonWidget import org.xtimms.psu.schedule.widget.NextLessonWidget
import android.content.Context import android.content.Context
import androidx.glance.appwidget.updateAll import androidx.glance.appwidget.updateAll
import androidx.work.Constraints import androidx.work.Constraints
@@ -11,9 +11,9 @@ import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager import androidx.work.WorkManager
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import ritm.schedule.app import org.xtimms.psu.schedule.app
import ritm.schedule.notify.Reminders import org.xtimms.psu.schedule.notify.Reminders
import ritm.schedule.widget.TodayWidget import org.xtimms.psu.schedule.widget.TodayWidget
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
/** /**

View File

@@ -8,7 +8,7 @@ import { join } from "node:path";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { parseWeekRanges, parseTextDates } from "../shared/weeks.js"; import { parseWeekRanges, parseTextDates } from "../shared/weeks.js";
const run = promisify(execFile); const run = promisify(execFile);
export const PARSER_VERSION = 7; export const PARSER_VERSION = 8;
const days = [ const days = [
"понедельник", "понедельник",
"вторник", "вторник",
@@ -24,10 +24,13 @@ const clean = (value) =>
.replace(/[\t ]+/g, " ") .replace(/[\t ]+/g, " ")
.replace(/\s*\n\s*/g, "\n") .replace(/\s*\n\s*/g, "\n")
.trim(); .trim();
const groupsIn = (value) => // A group is a two-digit year and a capitalised abbreviation: "23-БА", "24-ДОи", "25-ИЯ(РА)",
// "23-ИНС-1". Header cells also carry practice dates ("02.11-22.11-производственная практика",
// "09.11 по 06.12"): a number right after a date's dot or another digit is never a group.
export const groupsIn = (value) =>
[ [
...value.matchAll( ...value.matchAll(
/\b\d{2}\s*[-–]?\s*[А-ЯЁа-яё]{2,8}(?:\s*\([А-ЯЁа-яё]+\))?(?:\s*[-–]\s*\d)?/gu, /(?<![\d.,:/])\b\d{2}\s*[-–]?\s*[А-ЯЁ][А-ЯЁа-яё]{1,7}(?:\s*\([А-ЯЁа-яё]+\))?(?:\s*[-–]\s*\d)?/gu,
), ),
].map((m) => ].map((m) =>
m[0] m[0]

View File

@@ -1,7 +1,7 @@
import test from "node:test"; import test from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { parseDocx, parseWord } from "../server/parser.js"; import { groupsIn, parseDocx, parseWord } from "../server/parser.js";
import { decodeListing, fileMetadata } from "../server/source.js"; import { decodeListing, fileMetadata } from "../server/source.js";
const fixture = (name) => const fixture = (name) =>
readFileSync(new URL(`./fixtures/${name}.docx`, import.meta.url)); readFileSync(new URL(`./fixtures/${name}.docx`, import.meta.url));
@@ -104,6 +104,26 @@ test("Дневные таблицы четырёх факультетов рас
); );
} }
}); });
test("Даты практики в заголовке группы не становятся группами", () => {
// Заголовки из каталога 2026–2027 (ГФ и ФКНЭ).
assert.deepEqual(
groupsIn("23-ДО\n(02.11-22.11-производственная практика)"),
["23-ДО"],
);
assert.deepEqual(
groupsIn("24-ПП\n(09.11 по 06.12-производственная практика)"),
["24-ПП"],
);
assert.deepEqual(
groupsIn("23-ТТиИ (16.11-29.11-учебная практика)"),
["23-ТТиИ"],
);
assert.deepEqual(groupsIn("25-ИЯ(РА) 23 ИНС-1 24ДОи"), [
"25-ИЯ(РА)",
"23-ИНС-1",
"24-ДОи",
]);
});
test("Сессии: отдельные колонки времени, аудитории и преподавателя", () => { test("Сессии: отдельные колонки времени, аудитории и преподавателя", () => {
const s = parseDocx(fixture("fit"), { name: "26-ИТзс.docx" }), const s = parseDocx(fixture("fit"), { name: "26-ИТзс.docx" }),
l = s.lessons[0]; l = s.lessons[0];