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:
@@ -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 по дням недели.
|
||||||
- Предметы: пары по дням текущей недели; у каждого предмета — сокращение как в недельной сетке, следующее занятие с аудиторией, преподаватели и сколько пар на этой неделе. У каждого предмета свой постоянный цвет во всех разделах.
|
- Предметы: пары по дням текущей недели; у каждого предмета — сокращение как в недельной сетке, следующее занятие с аудиторией, преподаватели и сколько пар на этой неделе. У каждого предмета свой постоянный цвет во всех разделах.
|
||||||
|
|||||||
@@ -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 }) {
|
||||||
|
|||||||
4
android/app/proguard-rules.pro
vendored
4
android/app/proguard-rules.pro
vendored
@@ -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
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
@@ -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>(
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package ritm.schedule.data
|
package org.xtimms.psu.schedule.data
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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+ ("Будильники и напоминания"). */
|
||||||
@@ -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
|
||||||
@@ -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. */
|
||||||
@@ -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)) {
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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) {
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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. */
|
||||||
@@ -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(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
@@ -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.
|
||||||
@@ -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(
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -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 }) } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
)
|
)
|
||||||
@@ -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(
|
||||||
@@ -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() {
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -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]
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
Reference in New Issue
Block a user