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/` — нативный клиент на 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 по дням недели.
|
||||
- Предметы: пары по дням текущей недели; у каждого предмета — сокращение как в недельной сетке, следующее занятие с аудиторией, преподаватели и сколько пар на этой неделе. У каждого предмета свой постоянный цвет во всех разделах.
|
||||
|
||||
@@ -10,15 +10,15 @@ val apiUrl = providers.gradleProperty("PSU_API_URL")
|
||||
.map { if (it.endsWith("/")) it else "$it/" }
|
||||
|
||||
android {
|
||||
namespace = "ritm.schedule"
|
||||
namespace = "org.xtimms.psu.schedule"
|
||||
compileSdk {
|
||||
version = release(37) { minorApiLevel = 1 }
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "ritm.schedule"
|
||||
applicationId = "org.xtimms.psu.schedule"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
targetSdk = 37
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
buildConfigField("String", "API_URL", "\"${apiUrl.get()}\"")
|
||||
@@ -27,6 +27,7 @@ android {
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||
// A release build must talk to a public HTTPS server.
|
||||
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.
|
||||
-keepattributes *Annotation*, InnerClasses
|
||||
-keep,includedescriptorclasses class ritm.schedule.data.**$$serializer { *; }
|
||||
-keepclassmembers class ritm.schedule.data.** { *** Companion; }
|
||||
-keep,includedescriptorclasses class org.xtimms.psu.schedule.data.**$$serializer { *; }
|
||||
-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.material.icons.outlined.CalendarViewWeek
|
||||
import androidx.compose.material.icons.outlined.Checklist
|
||||
import androidx.compose.material.icons.rounded.CalendarViewWeek
|
||||
import androidx.compose.material.icons.rounded.Checklist
|
||||
import ritm.schedule.ui.CalendarScreen
|
||||
import ritm.schedule.ui.PlansScreen
|
||||
import org.xtimms.psu.schedule.ui.CalendarScreen
|
||||
import org.xtimms.psu.schedule.ui.PlansScreen
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
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.platform.LocalDensity
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material.icons.automirrored.rounded.MenuOpen
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.FloatingActionButtonDefaults
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import ritm.schedule.ui.materialFadeThrough
|
||||
import ritm.schedule.ui.Emphasized
|
||||
import ritm.schedule.ui.MotionDuration
|
||||
import org.xtimms.psu.schedule.ui.materialFadeThrough
|
||||
import org.xtimms.psu.schedule.ui.Emphasized
|
||||
import org.xtimms.psu.schedule.ui.MotionDuration
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.animation.core.tween
|
||||
import ritm.schedule.ui.materialSharedAxisX
|
||||
import ritm.schedule.ui.rememberSlideDistance
|
||||
import android.content.res.Configuration
|
||||
import org.xtimms.psu.schedule.ui.materialSharedAxisX
|
||||
import org.xtimms.psu.schedule.ui.rememberSlideDistance
|
||||
import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
@@ -46,10 +50,6 @@ import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.viewModels
|
||||
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.BoxWithConstraints
|
||||
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.material3.IconButton
|
||||
import androidx.compose.material.icons.rounded.School
|
||||
import androidx.compose.material.icons.rounded.SwapHoriz
|
||||
import androidx.compose.material.icons.rounded.TravelExplore
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalWideNavigationRail
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.WideNavigationRail
|
||||
@@ -97,35 +95,32 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
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.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.launch
|
||||
import ritm.schedule.data.ThemeMode
|
||||
import ritm.schedule.data.today
|
||||
import ritm.schedule.ui.AppNav
|
||||
import ritm.schedule.ui.GroupsScreen
|
||||
import ritm.schedule.ui.HomeScreen
|
||||
import ritm.schedule.ui.LocalDisplay
|
||||
import ritm.schedule.ui.LocalMarks
|
||||
import ritm.schedule.ui.LocalMenuProgress
|
||||
import ritm.schedule.ui.theme.card
|
||||
import org.xtimms.psu.schedule.data.ThemeMode
|
||||
import org.xtimms.psu.schedule.data.today
|
||||
import org.xtimms.psu.schedule.ui.AppNav
|
||||
import org.xtimms.psu.schedule.ui.GroupsScreen
|
||||
import org.xtimms.psu.schedule.ui.HomeScreen
|
||||
import org.xtimms.psu.schedule.ui.Onboarding
|
||||
import org.xtimms.psu.schedule.ui.LocalDisplay
|
||||
import org.xtimms.psu.schedule.ui.LocalMarks
|
||||
import org.xtimms.psu.schedule.ui.LocalMenuProgress
|
||||
import org.xtimms.psu.schedule.ui.theme.card
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import ritm.schedule.ui.LocalNotes
|
||||
import ritm.schedule.ui.LocalSubjectSlots
|
||||
import ritm.schedule.data.Display
|
||||
import ritm.schedule.ui.MainViewModel
|
||||
import ritm.schedule.ui.ScheduleScreen
|
||||
import ritm.schedule.ui.SearchScreen
|
||||
import ritm.schedule.ui.SettingsScreen
|
||||
import ritm.schedule.ui.SubjectsScreen
|
||||
import ritm.schedule.ui.subjectSlots
|
||||
import ritm.schedule.ui.theme.ScheduleTheme
|
||||
import ritm.schedule.ui.theme.isDark
|
||||
import ritm.schedule.ui.theme.page
|
||||
import org.xtimms.psu.schedule.ui.LocalNotes
|
||||
import org.xtimms.psu.schedule.ui.LocalSubjectSlots
|
||||
import org.xtimms.psu.schedule.data.Display
|
||||
import org.xtimms.psu.schedule.ui.MainViewModel
|
||||
import org.xtimms.psu.schedule.ui.ScheduleScreen
|
||||
import org.xtimms.psu.schedule.ui.SearchScreen
|
||||
import org.xtimms.psu.schedule.ui.SettingsScreen
|
||||
import org.xtimms.psu.schedule.ui.SubjectsScreen
|
||||
import org.xtimms.psu.schedule.ui.subjectSlots
|
||||
import org.xtimms.psu.schedule.ui.theme.ScheduleTheme
|
||||
import org.xtimms.psu.schedule.ui.theme.isDark
|
||||
import org.xtimms.psu.schedule.ui.theme.page
|
||||
import java.time.LocalDate
|
||||
|
||||
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) {
|
||||
Surface(color = MaterialTheme.colorScheme.page, modifier = Modifier.fillMaxSize()) {
|
||||
// Wait for the stored settings so a returning student never sees the first-run screen.
|
||||
if (s != null) App(vm, firstRun = s.current == null)
|
||||
// Wait for the stored settings so a returning student never sees the onboarding.
|
||||
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). */
|
||||
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)
|
||||
@Composable
|
||||
private fun App(vm: MainViewModel, firstRun: Boolean) {
|
||||
var tab by rememberSaveable { mutableStateOf(Tab.Home) }
|
||||
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 menuOpen by rememberSaveable { mutableStateOf(false) }
|
||||
val settings by vm.settings.collectAsStateWithLifecycle()
|
||||
val timetable by vm.timetable.collectAsStateWithLifecycle()
|
||||
val hasGroup = settings?.current != null
|
||||
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()
|
||||
// Back closes a page first, then returns to the overview instead of closing the app.
|
||||
BackHandler(enabled = page != null && hasGroup) { page = null }
|
||||
BackHandler(enabled = page == null && tab != Tab.Home) { tab = Tab.Home }
|
||||
// Declared last, so an open menu closes before anything else.
|
||||
BackHandler(enabled = menuOpen) { menuOpen = false }
|
||||
// Predictive back: the gesture scrubs the very transition a tap would play, so the
|
||||
// student sees where back leads before letting go; a cancelled swipe springs back.
|
||||
val pageState = remember { SeekableTransitionState(page) }
|
||||
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 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.
|
||||
BoxWithConstraints(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.card)) {
|
||||
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) {
|
||||
AppNav(
|
||||
openGroups = { page = Page.Groups },
|
||||
openSettings = { page = Page.Settings },
|
||||
openDay = { focus = it; weekView = false; tab = Tab.Schedule },
|
||||
openWeek = { focus = today(); weekView = true; tab = Tab.Schedule },
|
||||
openDay = { focus = it; weekChoice = false; tab = Tab.Schedule },
|
||||
openWeek = { focus = today(); weekChoice = true; tab = Tab.Schedule },
|
||||
openSubjects = { tab = Tab.Subjects },
|
||||
openSearch = { tab = Tab.Search },
|
||||
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
|
||||
// (md.sys.motion.easing.emphasized): long2 500 ms to open, medium4 400 ms to close.
|
||||
val opening = menuOpen && !wide
|
||||
val openState = animateFloatAsState(
|
||||
val openAnim = remember { Animatable(0f) }
|
||||
LaunchedEffect(opening) {
|
||||
openAnim.animateTo(
|
||||
if (opening) 1f else 0f,
|
||||
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.
|
||||
val open by openState
|
||||
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,
|
||||
// so rotating the phone keeps its saved state (selected day, week view).
|
||||
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,
|
||||
expanded = (rail.targetValue == WideNavigationRailValue.Expanded),
|
||||
items = menuItems,
|
||||
@@ -295,11 +369,9 @@ private fun App(vm: MainViewModel, firstRun: Boolean) {
|
||||
LocalNotes provides notes,
|
||||
LocalMarks provides marks,
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = page,
|
||||
rememberTransition(pageState, label = "page").AnimatedContent(
|
||||
// Shared axis X: forward into a page, back out of it, as in RideBus.
|
||||
transitionSpec = { materialSharedAxisX(forward = targetState != null, distance = slide) },
|
||||
label = "page",
|
||||
transitionSpec = { materialSharedAxisX(forward = targetState != null, distance = slide, predictive = gesture) },
|
||||
) { current ->
|
||||
// Pages pad for side insets; their lists end above the navigation bar themselves.
|
||||
val pageInsets = Modifier.fillMaxSize()
|
||||
@@ -313,10 +385,8 @@ private fun App(vm: MainViewModel, firstRun: Boolean) {
|
||||
)
|
||||
}
|
||||
Page.Settings -> Box(pageInsets) { SettingsScreen(vm, onBack = { page = null }) }
|
||||
null -> AnimatedContent(
|
||||
targetState = tab,
|
||||
null -> rememberTransition(tabState, label = "tab").AnimatedContent(
|
||||
transitionSpec = { materialFadeThrough() },
|
||||
label = "tab",
|
||||
) { shown ->
|
||||
when (shown) {
|
||||
Tab.Home -> HomeScreen(vm, nav)
|
||||
@@ -325,7 +395,7 @@ private fun App(vm: MainViewModel, firstRun: Boolean) {
|
||||
Tab.Schedule -> ScheduleScreen(
|
||||
vm, nav,
|
||||
weekView = weekView,
|
||||
onWeekView = { weekView = it },
|
||||
onWeekView = { weekChoice = it },
|
||||
focus = focus,
|
||||
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(
|
||||
Modifier
|
||||
.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(
|
||||
interactionSource = 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.
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
header = {
|
||||
IconButton(
|
||||
onClick = onToggle,
|
||||
modifier = Modifier.padding(start = if (expanded) 12.dp else 24.dp, bottom = if (expanded) 12.dp else 0.dp),
|
||||
) {
|
||||
// A 64dp row like the top app bar's, so the button sits level with the screen
|
||||
// title; 24dp in, so its icon lines up with the item icons below in both states.
|
||||
Box(Modifier.height(64.dp).padding(start = 24.dp), contentAlignment = Alignment.CenterStart) {
|
||||
IconButton(onClick = onToggle) {
|
||||
Icon(
|
||||
if (expanded) Icons.AutoMirrored.Rounded.MenuOpen else Icons.Rounded.Menu,
|
||||
contentDescription = if (expanded) "Закрыть меню" else "Открыть меню",
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
Column(
|
||||
@@ -405,8 +487,9 @@ private fun MenuRail(
|
||||
WideNavigationRailItem(
|
||||
selected = selected,
|
||||
onClick = { items.onTab(item); onPicked() },
|
||||
icon = { Icon(if (selected) item.selectedIcon else item.icon, contentDescription = null) },
|
||||
label = { Text(item.label) },
|
||||
// Collapsed, the rail shows icons only; the name stays for TalkBack.
|
||||
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,
|
||||
)
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
package ritm.schedule
|
||||
package org.xtimms.psu.schedule
|
||||
|
||||
import android.app.Application
|
||||
import ritm.schedule.data.Api
|
||||
import ritm.schedule.data.AttendanceStore
|
||||
import ritm.schedule.data.NotesStore
|
||||
import ritm.schedule.data.Repository
|
||||
import ritm.schedule.data.Settings
|
||||
import ritm.schedule.notify.Reminders
|
||||
import ritm.schedule.work.SyncWorker
|
||||
import org.xtimms.psu.schedule.data.Api
|
||||
import org.xtimms.psu.schedule.data.AttendanceStore
|
||||
import org.xtimms.psu.schedule.data.NotesStore
|
||||
import org.xtimms.psu.schedule.data.Repository
|
||||
import org.xtimms.psu.schedule.data.Settings
|
||||
import org.xtimms.psu.schedule.notify.Reminders
|
||||
import org.xtimms.psu.schedule.work.SyncWorker
|
||||
|
||||
class ScheduleApp : Application() {
|
||||
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.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
@@ -34,7 +35,7 @@ class Api(baseUrl: String) {
|
||||
val url = base.newBuilder().addPathSegments(path.trimStart('/')).apply {
|
||||
for ((name, value) in query) if (!value.isNullOrEmpty()) addQueryParameter(name, value)
|
||||
}.build()
|
||||
val body = try {
|
||||
val body = withRetry(url.toString()) {
|
||||
client.newCall(Request.Builder().url(url).build()).execute().use { response ->
|
||||
val text = response.body?.string().orEmpty()
|
||||
if (!response.isSuccessful) {
|
||||
@@ -49,12 +50,34 @@ class Api(baseUrl: String) {
|
||||
}
|
||||
text
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
throw ApiException("Нет связи с сервером расписания. Показана сохранённая копия.", offline = true)
|
||||
}
|
||||
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. */
|
||||
suspend fun timetable(docId: String, group: String, from: String, days: Int) =
|
||||
get<Timetable>(
|
||||
@@ -1,4 +1,4 @@
|
||||
package ritm.schedule.data
|
||||
package org.xtimms.psu.schedule.data
|
||||
|
||||
import android.content.Context
|
||||
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.LocalDate
|
||||
@@ -1,4 +1,4 @@
|
||||
package ritm.schedule.data
|
||||
package org.xtimms.psu.schedule.data
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package ritm.schedule.data
|
||||
package org.xtimms.psu.schedule.data
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -1,4 +1,4 @@
|
||||
package ritm.schedule.data
|
||||
package org.xtimms.psu.schedule.data
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -1,4 +1,4 @@
|
||||
package ritm.schedule.data
|
||||
package org.xtimms.psu.schedule.data
|
||||
|
||||
import android.content.Context
|
||||
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.app.AlarmManager
|
||||
import android.app.NotificationChannel
|
||||
@@ -17,14 +17,14 @@ import androidx.core.content.ContextCompat
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import ritm.schedule.MainActivity
|
||||
import ritm.schedule.R
|
||||
import ritm.schedule.app
|
||||
import ritm.schedule.data.MINSK
|
||||
import ritm.schedule.data.Timetable
|
||||
import ritm.schedule.data.UserSettings
|
||||
import ritm.schedule.data.parseDate
|
||||
import ritm.schedule.data.parseTime
|
||||
import org.xtimms.psu.schedule.MainActivity
|
||||
import org.xtimms.psu.schedule.R
|
||||
import org.xtimms.psu.schedule.app
|
||||
import org.xtimms.psu.schedule.data.MINSK
|
||||
import org.xtimms.psu.schedule.data.Timetable
|
||||
import org.xtimms.psu.schedule.data.UserSettings
|
||||
import org.xtimms.psu.schedule.data.parseDate
|
||||
import org.xtimms.psu.schedule.data.parseTime
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
@@ -34,7 +34,7 @@ import java.time.Instant
|
||||
object Reminders {
|
||||
private const val CHANNEL = "before_lesson"
|
||||
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
|
||||
|
||||
/** 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.content.ComponentName
|
||||
@@ -12,8 +12,8 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import ritm.schedule.MainActivity
|
||||
import ritm.schedule.widget.upcoming
|
||||
import org.xtimms.psu.schedule.MainActivity
|
||||
import org.xtimms.psu.schedule.widget.upcoming
|
||||
|
||||
/**
|
||||
* 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)
|
||||
|
||||
package ritm.schedule.ui
|
||||
package org.xtimms.psu.schedule.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import ritm.schedule.data.Lesson
|
||||
import ritm.schedule.data.Mark
|
||||
import ritm.schedule.data.nowTime
|
||||
import ritm.schedule.data.today
|
||||
import org.xtimms.psu.schedule.data.Lesson
|
||||
import org.xtimms.psu.schedule.data.Mark
|
||||
import org.xtimms.psu.schedule.data.nowTime
|
||||
import org.xtimms.psu.schedule.data.today
|
||||
import java.time.LocalDate
|
||||
|
||||
/** Attendance marks of the open group, for cards deep in the tree. */
|
||||
@@ -1,9 +1,10 @@
|
||||
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||
|
||||
package ritm.schedule.ui
|
||||
package org.xtimms.psu.schedule.ui
|
||||
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
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.shape.CircleShape
|
||||
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.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
@@ -88,15 +87,15 @@ import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.launch
|
||||
import ritm.schedule.data.Lesson
|
||||
import ritm.schedule.data.Note
|
||||
import ritm.schedule.data.Timetable
|
||||
import ritm.schedule.data.Week
|
||||
import ritm.schedule.data.longLabel
|
||||
import ritm.schedule.data.monday
|
||||
import ritm.schedule.data.today
|
||||
import ritm.schedule.data.weekdayName
|
||||
import ritm.schedule.ui.theme.page
|
||||
import org.xtimms.psu.schedule.data.Lesson
|
||||
import org.xtimms.psu.schedule.data.Note
|
||||
import org.xtimms.psu.schedule.data.Timetable
|
||||
import org.xtimms.psu.schedule.data.Week
|
||||
import org.xtimms.psu.schedule.data.longLabel
|
||||
import org.xtimms.psu.schedule.data.monday
|
||||
import org.xtimms.psu.schedule.data.today
|
||||
import org.xtimms.psu.schedule.data.weekdayName
|
||||
import org.xtimms.psu.schedule.ui.theme.page
|
||||
import java.time.LocalDate
|
||||
import java.time.YearMonth
|
||||
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) }
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Column(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.page).nestedScroll(connection)) {
|
||||
Column {
|
||||
val appBar: @Composable () -> Unit = {
|
||||
TopAppBar(
|
||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent),
|
||||
navigationIcon = { MenuButton(nav) },
|
||||
@@ -208,6 +205,10 @@ fun CalendarScreen(vm: MainViewModel, nav: AppNav) {
|
||||
}) { 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)) {
|
||||
WEEKDAYS.forEach {
|
||||
Text(
|
||||
@@ -223,8 +224,10 @@ fun CalendarScreen(vm: MainViewModel, nav: AppNav) {
|
||||
val m = base.plusMonths(page.toLong())
|
||||
// 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
|
||||
// Centred like the weekday row above it, so the columns line up on wide screens.
|
||||
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.TopCenter) {
|
||||
MonthGrid(
|
||||
modifier = Modifier
|
||||
modifier = if (!fold) Modifier else Modifier
|
||||
.clipToBounds()
|
||||
.layout { measurable, constraints ->
|
||||
val placeable = measurable.measure(constraints)
|
||||
@@ -235,14 +238,44 @@ fun CalendarScreen(vm: MainViewModel, nav: AppNav) {
|
||||
}
|
||||
},
|
||||
month = m,
|
||||
row = row,
|
||||
selected = selected,
|
||||
hasLessons = { d -> t != null && vm.slotsOn(t, d, subgroup).isNotEmpty() },
|
||||
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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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.
|
||||
Surface(
|
||||
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() }
|
||||
DayAgenda(
|
||||
vm, t, subgroup, selected, notes,
|
||||
onLesson = { details = it to selected },
|
||||
onNote = { editing = it },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
agenda(Modifier.fillMaxSize())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -316,6 +344,7 @@ fun CalendarScreen(vm: MainViewModel, nav: AppNav) {
|
||||
private fun MonthGrid(
|
||||
modifier: Modifier,
|
||||
month: YearMonth,
|
||||
row: Dp = ROW,
|
||||
selected: LocalDate,
|
||||
hasLessons: (LocalDate) -> Boolean,
|
||||
hasPlans: (LocalDate) -> Boolean,
|
||||
@@ -329,7 +358,7 @@ private fun MonthGrid(
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(ROW)
|
||||
.height(row)
|
||||
.padding(bottom = 2.dp)
|
||||
.background(
|
||||
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.
|
||||
*/
|
||||
@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,
|
||||
t: Timetable?,
|
||||
subgroup: String?,
|
||||
@@ -418,8 +468,8 @@ private fun DayAgenda(
|
||||
if (plans.isEmpty() && lessons.isEmpty()) {
|
||||
Box(modifier) {
|
||||
ContentColumn {
|
||||
// Scrolls, so a low sheet never squeezes the illustration out of shape.
|
||||
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(bottom = 80.dp + navBarPadding())) {
|
||||
// Centred in the sheet or the tablet's pane; scrolls rather than squeezing the illustration.
|
||||
CenteredEmpty(bottomInset = 80.dp + navBarPadding()) {
|
||||
if (known) Empty(
|
||||
"Свободный день",
|
||||
"${date.weekdayName()}, ${date.longLabel()}: ни пар, ни планов.",
|
||||
@@ -444,9 +494,9 @@ private fun DayAgenda(
|
||||
if (plans.isNotEmpty()) {
|
||||
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 ->
|
||||
item(key = note.id) {
|
||||
item(key = note.id) { AnimatedItem {
|
||||
TimelineItem(
|
||||
marker = { PlanMarker(note, onToggle = { vm.toggleNote(note.id) }) },
|
||||
over = note.subject.ifBlank { null },
|
||||
@@ -455,15 +505,15 @@ private fun DayAgenda(
|
||||
last = i == plans.lastIndex,
|
||||
onClick = { onNote(note) },
|
||||
)
|
||||
}
|
||||
} }
|
||||
}
|
||||
}
|
||||
if (lessons.isNotEmpty()) {
|
||||
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)
|
||||
}
|
||||
} }
|
||||
lessons.forEachIndexed { i, (slot, l) ->
|
||||
item(key = "${slot.start}|${l.key}") {
|
||||
val color = subjectColor(l.subject)
|
||||
@@ -555,7 +605,7 @@ private fun TimelineItem(
|
||||
modifier = Modifier
|
||||
.padding(top = markerTop)
|
||||
.size(MARKER)
|
||||
.background(MaterialTheme.colorScheme.surface, CircleShape),
|
||||
.background(LocalPaneColor.current ?: MaterialTheme.colorScheme.surface, CircleShape),
|
||||
) { marker() }
|
||||
}
|
||||
Column(Modifier.weight(1f).padding(start = 16.dp, top = 14.dp, bottom = 10.dp)) {
|
||||
@@ -1,7 +1,11 @@
|
||||
@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.appendInlineContent
|
||||
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.unit.sp
|
||||
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.layout.ColumnScope
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
@@ -20,19 +24,12 @@ import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.ui.graphics.lerp
|
||||
import ritm.schedule.ui.theme.page
|
||||
import ritm.schedule.ui.theme.card
|
||||
import ritm.schedule.ui.theme.page
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import org.xtimms.psu.schedule.ui.theme.page
|
||||
import org.xtimms.psu.schedule.ui.theme.card
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||
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.customActions
|
||||
import kotlin.math.abs
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
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.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
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.material3.rememberSearchBarState
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
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.heading
|
||||
import androidx.compose.ui.semantics.role
|
||||
import androidx.compose.ui.semantics.selected
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import ritm.schedule.data.Week
|
||||
import ritm.schedule.data.shortWeekday
|
||||
import ritm.schedule.data.today
|
||||
import ritm.schedule.data.weekSpan
|
||||
import ritm.schedule.ui.theme.WeekColors
|
||||
import org.xtimms.psu.schedule.data.Week
|
||||
import org.xtimms.psu.schedule.data.shortWeekday
|
||||
import org.xtimms.psu.schedule.data.today
|
||||
import org.xtimms.psu.schedule.data.weekSpan
|
||||
import org.xtimms.psu.schedule.ui.theme.WeekColors
|
||||
import java.time.LocalDate
|
||||
|
||||
/** 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
|
||||
fun appBarColors(): TopAppBarColors = TopAppBarDefaults.topAppBarColors(
|
||||
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. */
|
||||
@Composable
|
||||
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). */
|
||||
val MAX_CONTENT = 720.dp
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
@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.material3.TextButton
|
||||
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.History
|
||||
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.Column
|
||||
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.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
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.ui.input.nestedscroll.nestedScroll
|
||||
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.Check
|
||||
import androidx.compose.material.icons.rounded.Close
|
||||
import androidx.compose.material.icons.rounded.Groups
|
||||
import androidx.compose.material.icons.rounded.Search
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -44,8 +38,6 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LoadingIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
@@ -56,16 +48,14 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
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.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.delay
|
||||
import ritm.schedule.data.GroupResult
|
||||
import ritm.schedule.data.attempt
|
||||
import ritm.schedule.data.GroupsResponse
|
||||
import ritm.schedule.data.SavedGroup
|
||||
import org.xtimms.psu.schedule.data.GroupResult
|
||||
import org.xtimms.psu.schedule.data.attempt
|
||||
import org.xtimms.psu.schedule.data.GroupsResponse
|
||||
import org.xtimms.psu.schedule.data.SavedGroup
|
||||
|
||||
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)
|
||||
@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 timetable by vm.timetable.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 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)
|
||||
attempt { vm.api.groups(query.trim(), faculty, course) }
|
||||
.onSuccess { data = it; error = null }
|
||||
@@ -107,7 +105,7 @@ fun GroupsScreen(vm: MainViewModel, onOpened: () -> Unit, onBack: (() -> Unit)?)
|
||||
val subgroups = t?.subgroups.orEmpty()
|
||||
val s = vm.settings.value
|
||||
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) {
|
||||
@@ -120,7 +118,7 @@ fun GroupsScreen(vm: MainViewModel, onOpened: () -> Unit, onBack: (() -> Unit)?)
|
||||
|
||||
val scroll = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
Column(Modifier.fillMaxSize().nestedScroll(scroll.nestedScrollConnection)) {
|
||||
TopAppBar(
|
||||
if (!onboarding) TopAppBar(
|
||||
colors = appBarColors(),
|
||||
title = { Text("Группы", style = MaterialTheme.typography.titleLargeEmphasized) },
|
||||
navigationIcon = {
|
||||
@@ -136,7 +134,7 @@ fun GroupsScreen(vm: MainViewModel, onOpened: () -> Unit, onBack: (() -> Unit)?)
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
if (current == null) item {
|
||||
if (current == null || onboarding) item {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.padding(vertical = 8.dp)) {
|
||||
Heading("Найдите свою группу", style = MaterialTheme.typography.headlineMediumEmphasized)
|
||||
Text(
|
||||
@@ -147,9 +145,9 @@ fun GroupsScreen(vm: MainViewModel, onOpened: () -> Unit, onBack: (() -> Unit)?)
|
||||
}
|
||||
}
|
||||
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 ->
|
||||
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) {
|
||||
@@ -1,7 +1,13 @@
|
||||
@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.semantics.clearAndSetSemantics
|
||||
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.semantics.contentDescription
|
||||
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.runtime.saveable.rememberSaveable
|
||||
import ritm.schedule.data.Note
|
||||
import ritm.schedule.data.parseDate
|
||||
import ritm.schedule.ui.theme.card
|
||||
import ritm.schedule.ui.theme.page
|
||||
import org.xtimms.psu.schedule.data.Note
|
||||
import org.xtimms.psu.schedule.data.parseDate
|
||||
import org.xtimms.psu.schedule.ui.theme.card
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
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.LazyRow
|
||||
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.SwapHoriz
|
||||
import androidx.compose.material.icons.rounded.Today
|
||||
import androidx.compose.material.icons.rounded.TravelExplore
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
@@ -49,7 +52,6 @@ import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
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.Groups
|
||||
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.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LinearWavyProgressIndicator
|
||||
import androidx.compose.material3.LoadingIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
@@ -85,17 +86,17 @@ import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.delay
|
||||
import ritm.schedule.data.Lesson
|
||||
import ritm.schedule.data.Timetable
|
||||
import ritm.schedule.data.longLabel
|
||||
import ritm.schedule.data.minutesLabel
|
||||
import ritm.schedule.data.monday
|
||||
import ritm.schedule.data.nowTime
|
||||
import ritm.schedule.data.parseTime
|
||||
import ritm.schedule.data.plural
|
||||
import ritm.schedule.data.today
|
||||
import ritm.schedule.data.weekSpan
|
||||
import ritm.schedule.data.weekdayName
|
||||
import org.xtimms.psu.schedule.data.Lesson
|
||||
import org.xtimms.psu.schedule.data.Timetable
|
||||
import org.xtimms.psu.schedule.data.longLabel
|
||||
import org.xtimms.psu.schedule.data.minutesLabel
|
||||
import org.xtimms.psu.schedule.data.monday
|
||||
import org.xtimms.psu.schedule.data.nowTime
|
||||
import org.xtimms.psu.schedule.data.parseTime
|
||||
import org.xtimms.psu.schedule.data.plural
|
||||
import org.xtimms.psu.schedule.data.today
|
||||
import org.xtimms.psu.schedule.data.weekSpan
|
||||
import org.xtimms.psu.schedule.data.weekdayName
|
||||
import java.time.Duration
|
||||
import java.time.LocalDate
|
||||
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
|
||||
when {
|
||||
t == null && status.loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { LoadingIndicator() }
|
||||
t == null -> Empty(
|
||||
t == null -> CenteredEmpty {
|
||||
Empty(
|
||||
"Расписание не загружено",
|
||||
"Выберите группу — обзор дня и недели появится здесь.",
|
||||
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)) {
|
||||
Banner(t.note ?: "Даты для этого расписания не рассчитаны.", offline = false, onClose = null)
|
||||
CenteredEmpty {
|
||||
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 next = (1..7).asSequence()
|
||||
.map { date.plusDays(it.toLong()) }
|
||||
@@ -195,19 +203,22 @@ fun HomeScreen(vm: MainViewModel, nav: AppNav) {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 24.dp + navBarPadding()),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
modifier = listModifier,
|
||||
) {
|
||||
item { QuickActions(nav, Modifier.bleed(16.dp)) }
|
||||
item { WeekReport(vm, t, subgroup, date, clock) }
|
||||
item { SessionCard(vm, t, onOpen = { session = true }) }
|
||||
item { DayTitle("Сегодня", date) }
|
||||
item { TodayCard(todaySlots, clock, onOpen = { details = it to date }, onDay = { nav.openDay(date) }) }
|
||||
item { AttendanceCard(vm, todaySlots) }
|
||||
item { TasksCard(vm, onEdit = { editing = it }, onAll = nav.openPlans) }
|
||||
// Shortcuts are a phone touch: on a tablet the menu and the week pane are always in view.
|
||||
// Cards that appear, empty out or grow (attendance once marked, plans as they
|
||||
// are ticked off) ease the rest of the overview into place.
|
||||
if (!twoPane) item(key = "actions") { QuickActions(nav, Modifier.bleed(16.dp)) }
|
||||
item(key = "week") { AnimatedItem { WeekReport(vm, t, subgroup, date, clock) } }
|
||||
item(key = "session") { AnimatedItem { SessionCard(vm, t, onOpen = { session = true }) } }
|
||||
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) {
|
||||
val (day, slots) = next
|
||||
item { DayTitle(if (day == date.plusDays(1)) "Завтра" else day.weekdayName(), day) }
|
||||
item {
|
||||
item(key = "next-title") { AnimatedItem { DayTitle(if (day == date.plusDays(1)) "Завтра" else day.weekdayName(), day) } }
|
||||
item(key = "next") {
|
||||
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 }) } }
|
||||
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 ->
|
||||
@@ -549,3 +568,48 @@ private fun HomeMeta(icon: ImageVector, text: String) {
|
||||
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.getValue
|
||||
@@ -10,19 +10,19 @@ import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import ritm.schedule.data.Note
|
||||
import ritm.schedule.data.groupKey
|
||||
import org.xtimms.psu.schedule.data.Note
|
||||
import org.xtimms.psu.schedule.data.groupKey
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import ritm.schedule.app
|
||||
import ritm.schedule.data.ApiException
|
||||
import ritm.schedule.data.Display
|
||||
import ritm.schedule.data.GroupResult
|
||||
import ritm.schedule.data.SavedGroup
|
||||
import ritm.schedule.data.ThemeMode
|
||||
import ritm.schedule.data.Timetable
|
||||
import ritm.schedule.data.UserSettings
|
||||
import ritm.schedule.work.SyncWorker
|
||||
import org.xtimms.psu.schedule.app
|
||||
import org.xtimms.psu.schedule.data.ApiException
|
||||
import org.xtimms.psu.schedule.data.Display
|
||||
import org.xtimms.psu.schedule.data.GroupResult
|
||||
import org.xtimms.psu.schedule.data.SavedGroup
|
||||
import org.xtimms.psu.schedule.data.ThemeMode
|
||||
import org.xtimms.psu.schedule.data.Timetable
|
||||
import org.xtimms.psu.schedule.data.UserSettings
|
||||
import org.xtimms.psu.schedule.work.SyncWorker
|
||||
|
||||
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
|
||||
|
||||
/** 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. */
|
||||
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())
|
||||
|
||||
/** 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)
|
||||
list.filter { it.group == key }
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
/** 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
|
||||
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) }
|
||||
@@ -94,14 +94,26 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
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 {
|
||||
_status.value = _status.value.copy(loading = true)
|
||||
val result = block()
|
||||
val error = result.exceptionOrNull()
|
||||
val offline = (error as? ApiException)?.offline == true
|
||||
lastFailed = if (error != null) block else null
|
||||
_status.value = Status(
|
||||
loading = false,
|
||||
message = error?.message,
|
||||
offline = (error as? ApiException)?.offline == true,
|
||||
// Offline with a timetable on the device still leaves something to look at.
|
||||
message = error?.message?.let { if (offline && timetable.value != null) "$it Показана сохранённая копия." else it },
|
||||
offline = offline,
|
||||
)
|
||||
SyncWorker.afterChange(getApplication())
|
||||
return result.isSuccess
|
||||
@@ -119,7 +131,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
fun setTheme(mode: ThemeMode) = viewModelScope.launch { app.settings.setTheme(mode) }
|
||||
fun setDynamicColor(on: Boolean) = viewModelScope.launch { app.settings.setDynamicColor(on) }
|
||||
fun setDisplay(display: Display) = viewModelScope.launch { app.settings.setDisplay(display) }
|
||||
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 {
|
||||
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.EnterTransition
|
||||
import androidx.compose.animation.ExitTransition
|
||||
@@ -32,17 +39,22 @@ fun rememberSlideDistance(): Int {
|
||||
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 } +
|
||||
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 } +
|
||||
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. */
|
||||
fun materialSharedAxisX(forward: Boolean, distance: Int): ContentTransform =
|
||||
sharedAxisXIn(forward, distance) togetherWith sharedAxisXOut(forward, distance)
|
||||
fun materialSharedAxisX(forward: Boolean, distance: Int, predictive: Boolean = false): ContentTransform =
|
||||
sharedAxisXIn(forward, distance, predictive) togetherWith sharedAxisXOut(forward, distance, predictive)
|
||||
|
||||
/**
|
||||
* 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. */
|
||||
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. */
|
||||
fun materialFadeThrough(): ContentTransform =
|
||||
(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.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.IconButton
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -1,6 +1,6 @@
|
||||
@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.material3.ExperimentalMaterial3Api
|
||||
@@ -52,15 +52,15 @@ import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import ritm.schedule.data.Lesson
|
||||
import ritm.schedule.data.Note
|
||||
import ritm.schedule.data.Timetable
|
||||
import ritm.schedule.data.longLabel
|
||||
import ritm.schedule.data.nowTime
|
||||
import ritm.schedule.data.parseDate
|
||||
import ritm.schedule.data.shortWeekday
|
||||
import ritm.schedule.data.today
|
||||
import ritm.schedule.data.weekdayName
|
||||
import org.xtimms.psu.schedule.data.Lesson
|
||||
import org.xtimms.psu.schedule.data.Note
|
||||
import org.xtimms.psu.schedule.data.Timetable
|
||||
import org.xtimms.psu.schedule.data.longLabel
|
||||
import org.xtimms.psu.schedule.data.nowTime
|
||||
import org.xtimms.psu.schedule.data.parseDate
|
||||
import org.xtimms.psu.schedule.data.shortWeekday
|
||||
import org.xtimms.psu.schedule.data.today
|
||||
import org.xtimms.psu.schedule.data.weekdayName
|
||||
import java.time.LocalDate
|
||||
|
||||
/** 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)
|
||||
|
||||
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.material.icons.rounded.EventNote
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
@@ -70,19 +81,19 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.launch
|
||||
import ritm.schedule.data.Lesson
|
||||
import ritm.schedule.data.Note
|
||||
import ritm.schedule.data.PlansLayout
|
||||
import ritm.schedule.data.PlansPrefs
|
||||
import ritm.schedule.data.Timetable
|
||||
import ritm.schedule.data.longLabel
|
||||
import ritm.schedule.data.monday
|
||||
import ritm.schedule.data.parseDate
|
||||
import ritm.schedule.data.plural
|
||||
import ritm.schedule.data.today
|
||||
import ritm.schedule.data.weekdayName
|
||||
import ritm.schedule.ui.theme.card
|
||||
import ritm.schedule.ui.theme.page
|
||||
import org.xtimms.psu.schedule.data.Lesson
|
||||
import org.xtimms.psu.schedule.data.Note
|
||||
import org.xtimms.psu.schedule.data.PlansLayout
|
||||
import org.xtimms.psu.schedule.data.PlansPrefs
|
||||
import org.xtimms.psu.schedule.data.Timetable
|
||||
import org.xtimms.psu.schedule.data.longLabel
|
||||
import org.xtimms.psu.schedule.data.monday
|
||||
import org.xtimms.psu.schedule.data.parseDate
|
||||
import org.xtimms.psu.schedule.data.plural
|
||||
import org.xtimms.psu.schedule.data.today
|
||||
import org.xtimms.psu.schedule.data.weekdayName
|
||||
import org.xtimms.psu.schedule.ui.theme.card
|
||||
import org.xtimms.psu.schedule.ui.theme.page
|
||||
import java.time.LocalDate
|
||||
|
||||
/** "Сегодня • Суббота • 17 октября", "Завтра • …" or "Понедельник • 19 октября". */
|
||||
@@ -115,9 +126,13 @@ fun PlansScreen(vm: MainViewModel, nav: AppNav) {
|
||||
var editing by remember { mutableStateOf<Note?>(null) }
|
||||
var adding 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 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 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 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.
|
||||
val rows = remember(all, timetable, prefs, settings?.subgroup, vm.hidden) {
|
||||
if (prefs.layout == PlansLayout.Board) emptyList() else rows(days, byDay, overdue, lessonsOf, agenda)
|
||||
}
|
||||
val agendaRows = remember(all, timetable, prefs, settings?.subgroup, vm.hidden) { rows(days, byDay, overdue, lessonsOf, agenda = true) }
|
||||
val plainRows = remember(all, timetable, prefs, settings?.subgroup, vm.hidden) { rows(days, byDay, overdue, lessonsOf, agenda = false) }
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Column(Modifier.background(if (agenda) weekTint(week) else MaterialTheme.colorScheme.page)) {
|
||||
val appBar: @Composable () -> Unit = {
|
||||
TopAppBar(
|
||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent),
|
||||
navigationIcon = { MenuButton(nav) },
|
||||
@@ -158,27 +170,83 @@ fun PlansScreen(vm: MainViewModel, nav: AppNav) {
|
||||
actions = {
|
||||
if (agenda && date != today()) IconButton(onClick = {
|
||||
date = today()
|
||||
scope.launch { list.scrollToItem(0) }
|
||||
scope.launch { agendaList.scrollToItem(0) }
|
||||
}) { Icon(Icons.Rounded.Today, 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
|
||||
val index = rows.indexOfFirst { it is Row.Header && it.date == d }
|
||||
if (index >= 0) scope.launch { list.scrollToItem(index) }
|
||||
val index = agendaRows.indexOfFirst { it is Row.Header && it.date == d }
|
||||
if (index >= 0) scope.launch { agendaList.scrollToItem(index) }
|
||||
}
|
||||
}
|
||||
when (prefs.layout) {
|
||||
PlansLayout.Board -> Board(days, byDay, overdue, lessonsOf, vm::toggleNote, onEdit = { editing = it })
|
||||
else -> {
|
||||
// The strip follows the list: the day at the top of the list is the selected one.
|
||||
if (agenda) LaunchedEffect(rows) {
|
||||
val planList: @Composable (PlansLayout, Boolean, Color) -> Unit = { layout, follow, color ->
|
||||
val list = if (layout == PlansLayout.Agenda) agendaList else plainList
|
||||
val rows = if (layout == PlansLayout.Agenda) agendaRows else plainRows
|
||||
// The selected day follows the list: the day at its top is the one shown elsewhere.
|
||||
if (follow) LaunchedEffect(rows, list) {
|
||||
snapshotFlow { list.firstVisibleItemIndex }.collect { i ->
|
||||
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 = "Новое задание") }
|
||||
}
|
||||
|
||||
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) }
|
||||
val group = vm.currentGroupKey()
|
||||
if (adding) ModalBottomSheet(onDismissRequest = { adding = false }) {
|
||||
@@ -232,25 +305,43 @@ private fun rows(
|
||||
}
|
||||
|
||||
@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()) {
|
||||
CenteredEmpty(Modifier.background(color)) {
|
||||
Empty("Планов нет", "Задания и заметки к парам появятся здесь. Добавьте первое кнопкой «+».", icon = Icons.Rounded.Checklist)
|
||||
}
|
||||
return
|
||||
}
|
||||
LazyColumn(
|
||||
state = list,
|
||||
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) {
|
||||
is Row.Header -> stickyHeader(key = "h$i") { DayHeader(row) }
|
||||
is Row.Lessons -> item(key = "l$i") { LessonsSummary(row.slots, Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) }
|
||||
is Row.Header -> stickyHeader(key = "h$day") { Box(Modifier.animateItem()) { DayHeader(row, color) } }
|
||||
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}") {
|
||||
Box(Modifier.animateItem()) {
|
||||
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. */
|
||||
@Composable
|
||||
private fun DayHeader(h: Row.Header) {
|
||||
private fun DayHeader(h: Row.Header, color: Color) {
|
||||
Text(
|
||||
h.title,
|
||||
style = MaterialTheme.typography.titleSmallEmphasized,
|
||||
@@ -270,7 +361,7 @@ private fun DayHeader(h: Row.Header) {
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.background(color)
|
||||
.padding(start = 20.dp, end = 20.dp, top = 14.dp, bottom = 10.dp)
|
||||
.semantics { heading() },
|
||||
)
|
||||
@@ -309,6 +400,7 @@ private fun Board(
|
||||
lessonsOf: (LocalDate) -> List<Slot>,
|
||||
onToggle: (String) -> Unit,
|
||||
onEdit: (Note) -> Unit,
|
||||
wide: Boolean = false,
|
||||
) {
|
||||
val columns: List<LocalDate?> = (if (overdue.isNotEmpty()) listOf<LocalDate?>(null) else emptyList()) + days
|
||||
val pager: PagerState = rememberPagerState { columns.size }
|
||||
@@ -316,6 +408,8 @@ private fun Board(
|
||||
HorizontalPager(
|
||||
state = pager,
|
||||
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,
|
||||
verticalAlignment = Alignment.Top,
|
||||
modifier = Modifier.weight(1f),
|
||||
@@ -1,13 +1,24 @@
|
||||
@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.Check
|
||||
import androidx.compose.foundation.interaction.collectIsDraggedAsState
|
||||
import androidx.compose.material.icons.rounded.VisibilityOff
|
||||
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.ui.graphics.Color
|
||||
import kotlin.math.abs
|
||||
@@ -24,16 +35,14 @@ import androidx.compose.material.icons.rounded.Today
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
import ritm.schedule.ui.theme.card
|
||||
import ritm.schedule.ui.theme.page
|
||||
import org.xtimms.psu.schedule.ui.theme.card
|
||||
import org.xtimms.psu.schedule.ui.theme.page
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
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.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.CloudOff
|
||||
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.Refresh
|
||||
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.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.material.icons.rounded.Schedule
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -90,23 +93,17 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import ritm.schedule.data.Bell
|
||||
import ritm.schedule.data.Lesson
|
||||
import ritm.schedule.data.Timetable
|
||||
import ritm.schedule.data.dayName
|
||||
import ritm.schedule.data.longLabel
|
||||
import ritm.schedule.data.minutesLabel
|
||||
import ritm.schedule.data.monday
|
||||
import ritm.schedule.data.nowTime
|
||||
import ritm.schedule.data.parseDate
|
||||
import ritm.schedule.data.parseTime
|
||||
import ritm.schedule.data.today
|
||||
import ritm.schedule.data.weekdayName
|
||||
import org.xtimms.psu.schedule.data.Bell
|
||||
import org.xtimms.psu.schedule.data.Lesson
|
||||
import org.xtimms.psu.schedule.data.Timetable
|
||||
import org.xtimms.psu.schedule.data.dayName
|
||||
import org.xtimms.psu.schedule.data.minutesLabel
|
||||
import org.xtimms.psu.schedule.data.monday
|
||||
import org.xtimms.psu.schedule.data.parseDate
|
||||
import org.xtimms.psu.schedule.data.today
|
||||
import java.time.Duration
|
||||
import java.time.LocalDate
|
||||
|
||||
@@ -136,15 +133,18 @@ fun ScheduleScreen(
|
||||
title = { Text(t?.group ?: settings?.current?.group ?: "Расписание", style = MaterialTheme.typography.titleLargeEmphasized) },
|
||||
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 {
|
||||
t == null && status.loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { LoadingIndicator() }
|
||||
t == null -> Empty(
|
||||
t == null -> CenteredEmpty {
|
||||
Empty(
|
||||
"Расписание не загружено",
|
||||
"Проверьте подключение или выберите группу заново.",
|
||||
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 })
|
||||
}
|
||||
}
|
||||
@@ -255,8 +255,12 @@ private fun Planner(
|
||||
val onToday = if (weekView) grid == today().monday() else date == today()
|
||||
val doc = t.document
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Column(Modifier.background(tint)) {
|
||||
// Tablets keep the calm page behind the header and put the timetable on a card, as School
|
||||
// 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(
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = Color.Transparent,
|
||||
@@ -288,17 +292,24 @@ private fun Planner(
|
||||
if (!weekView) gridMonday = date.monday().toEpochDay()
|
||||
onWeekView(!weekView)
|
||||
}) {
|
||||
Crossfade(weekView, animationSpec = MaterialTheme.motionScheme.fastEffectsSpec(), label = "view-icon") { week ->
|
||||
Icon(
|
||||
if (weekView) Icons.Rounded.ViewDay else Icons.Rounded.CalendarViewWeek,
|
||||
contentDescription = if (weekView) "Показать день" else "Показать неделю",
|
||||
if (week) Icons.Rounded.ViewDay else Icons.Rounded.CalendarViewWeek,
|
||||
contentDescription = if (week) "Показать день" else "Показать неделю",
|
||||
)
|
||||
}
|
||||
}
|
||||
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]
|
||||
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(
|
||||
monday = monday,
|
||||
selected = date,
|
||||
@@ -309,13 +320,28 @@ private fun Planner(
|
||||
modifier = Modifier.widthIn(max = MAX_CONTENT).padding(horizontal = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
} }
|
||||
}
|
||||
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()) {
|
||||
if (weekView) {
|
||||
Surface(
|
||||
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.
|
||||
val weeks = rememberPagerState(initialPage = mondays.indexOf(grid).coerceAtLeast(0)) { mondays.size }
|
||||
LaunchedEffect(gridMonday) {
|
||||
@@ -331,7 +357,9 @@ private fun Planner(
|
||||
vm, t, subgroup, monday,
|
||||
onOpen = onOpen,
|
||||
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() }) {
|
||||
@@ -347,6 +375,8 @@ private fun Planner(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
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(),
|
||||
) {
|
||||
if (slots.isEmpty()) item {
|
||||
Box(Modifier.fillParentMaxHeight(0.85f).fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
Empty("Занятий нет", "По таблице недель в этот день свободно — можно отдохнуть.", icon = Icons.Rounded.Weekend)
|
||||
}
|
||||
}
|
||||
slots.forEach { slot ->
|
||||
val running = isToday && !clock.isBefore(slot.start) && clock.isBefore(slot.end)
|
||||
val status = when {
|
||||
@@ -371,6 +403,8 @@ private fun DayPage(date: LocalDate, slots: List<Slot>, onOpen: (Lesson, LocalDa
|
||||
else -> null
|
||||
}
|
||||
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(
|
||||
lesson,
|
||||
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. */
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@@ -615,7 +650,7 @@ private fun WordRows(t: Timetable, subgroup: String?, hidden: Set<String>, onOpe
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Banner(text: String, offline: Boolean, onClose: (() -> Unit)?) {
|
||||
fun Banner(text: String, offline: Boolean, onClose: (() -> Unit)?, onRetry: (() -> Unit)? = null) {
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.large,
|
||||
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)
|
||||
Spacer(Modifier.width(12.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") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Centres an empty state in the space it is given — a phone screen or a tablet's pane —
|
||||
* and scrolls when that space is too low, so the illustration is never squeezed.
|
||||
*/
|
||||
@Composable
|
||||
fun CenteredEmpty(
|
||||
modifier: Modifier = Modifier,
|
||||
/** Room kept clear inside the scroll (under a FAB): content scrolls past it, never gets cut. */
|
||||
bottomInset: Dp = 0.dp,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
BoxWithConstraints(modifier.fillMaxSize()) {
|
||||
val height = maxHeight
|
||||
Column(
|
||||
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).heightIn(min = height).padding(bottom = bottomInset),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) { content() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty state in the RideBus style: an icon on a seven-sided cookie shape, a title,
|
||||
* a calm explanation and an optional tonal action.
|
||||
@@ -1,7 +1,12 @@
|
||||
@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.animation.animateColorAsState
|
||||
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.rememberPagerState
|
||||
import androidx.compose.material.icons.rounded.PersonSearch
|
||||
import androidx.compose.material.icons.rounded.Schedule
|
||||
import androidx.compose.material.icons.rounded.Today
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -20,8 +23,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import java.time.temporal.ChronoUnit
|
||||
import androidx.compose.material.icons.rounded.SearchOff
|
||||
import androidx.compose.material.icons.rounded.EventAvailable
|
||||
import ritm.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.Column
|
||||
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.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.activity.compose.PredictiveBackHandler
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
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.MeetingRoom
|
||||
import androidx.compose.material.icons.rounded.Person
|
||||
import androidx.compose.material.icons.rounded.Search
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LoadingIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
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.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.delay
|
||||
import ritm.schedule.data.Bell
|
||||
import ritm.schedule.data.attempt
|
||||
import ritm.schedule.data.Coverage
|
||||
import ritm.schedule.data.DirectoryDay
|
||||
import ritm.schedule.data.DirectorySearch
|
||||
import ritm.schedule.data.Lesson
|
||||
import ritm.schedule.data.longLabel
|
||||
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 org.xtimms.psu.schedule.data.attempt
|
||||
import org.xtimms.psu.schedule.data.Coverage
|
||||
import org.xtimms.psu.schedule.data.DirectoryDay
|
||||
import org.xtimms.psu.schedule.data.DirectorySearch
|
||||
import org.xtimms.psu.schedule.data.Lesson
|
||||
import org.xtimms.psu.schedule.data.monday
|
||||
import org.xtimms.psu.schedule.data.today
|
||||
import java.time.LocalDate
|
||||
|
||||
private sealed interface Target {
|
||||
@@ -110,12 +100,14 @@ fun SearchScreen(vm: MainViewModel, nav: AppNav) {
|
||||
LaunchedEffect(Unit) {
|
||||
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 }
|
||||
delay(250)
|
||||
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
|
||||
loading = true
|
||||
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.
|
||||
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 dated = target != null
|
||||
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.
|
||||
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(),
|
||||
label = "search-header",
|
||||
)
|
||||
val weekColor = weekTint(week)
|
||||
|
||||
Column(Modifier.fillMaxSize().nestedScroll(scroll.nestedScrollConnection)) {
|
||||
Column(Modifier.background(if (dated) weekColor else scrolledTint)) {
|
||||
val appBar: @Composable () -> Unit = {
|
||||
TopAppBar(
|
||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent, scrolledContainerColor = Color.Transparent),
|
||||
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)) {
|
||||
ContentColumn {
|
||||
// [pane]: beside a detail pane the results stay while a day is open, and the idle
|
||||
// illustration stays out, since the pane already explains what to do.
|
||||
val results: @Composable (Modifier, Boolean) -> Unit = { modifier, pane ->
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 24.dp + navBarPadding()),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
modifier = modifier,
|
||||
) {
|
||||
error?.let { item { Banner(it, offline = true, onClose = null) } }
|
||||
|
||||
if (target == null) {
|
||||
error?.let { item { Banner(it, offline = true, onClose = null, onRetry = { error = null; retries++ }) } }
|
||||
if (target == null || pane) {
|
||||
item { SearchField(query, { query = it }, "Фамилия или номер аудитории") }
|
||||
val h = hits
|
||||
if (h == null) item {
|
||||
Empty(
|
||||
if (h == null && !pane) item {
|
||||
Box(Modifier.fillParentMaxHeight(0.75f).fillMaxWidth(), contentAlignment = Alignment.Center) { Empty(
|
||||
"Кто где",
|
||||
"Найдите преподавателя или аудиторию — покажем занятия на выбранный день по всем расписаниям университета.",
|
||||
icon = Icons.Rounded.PersonSearch,
|
||||
)
|
||||
} else {
|
||||
) }
|
||||
} else if (h != null) {
|
||||
if (h.teachers.isNotEmpty()) item {
|
||||
ListCard(Icons.Rounded.Person, "Преподаватели") {
|
||||
h.teachers.forEachIndexed { index, t ->
|
||||
@@ -225,17 +226,31 @@ fun SearchScreen(vm: MainViewModel, nav: AppNav) {
|
||||
}
|
||||
}
|
||||
if (h.teachers.isEmpty() && h.rooms.isEmpty()) item {
|
||||
Box(Modifier.fillParentMaxHeight(0.75f).fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
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) {
|
||||
val d = day
|
||||
if (loading && d == null) item { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { LoadingIndicator() } }
|
||||
if (d != null) {
|
||||
if (d.lessons.isEmpty()) item { Empty("Занятий нет", "По расписаниям каталога в этот день здесь пусто.", icon = Icons.Rounded.EventAvailable) }
|
||||
items(d.lessons, key = { "${it.start}|${it.subject}|${it.rooms}" }) { l ->
|
||||
if (d.lessons.isEmpty()) item {
|
||||
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(
|
||||
Lesson(
|
||||
key = l.start + l.subject,
|
||||
@@ -255,7 +270,7 @@ fun SearchScreen(vm: MainViewModel, nav: AppNav) {
|
||||
time = l.start + (l.end?.let { " → $it" } ?: ""),
|
||||
pair = bells.firstOrNull { it.start == l.start }?.pair,
|
||||
onClick = null,
|
||||
)
|
||||
) }
|
||||
}
|
||||
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. */
|
||||
@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() }
|
||||
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 }
|
||||
@@ -294,7 +390,9 @@ internal fun SearchWeekStrip(timetable: ritm.schedule.data.Timetable?, date: Loc
|
||||
HorizontalPager(state = pager, beyondViewportPageCount = 1) { page ->
|
||||
val monday = mondayAt(page)
|
||||
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,
|
||||
) {
|
||||
DayStrip(
|
||||
@@ -1,6 +1,6 @@
|
||||
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
|
||||
package ritm.schedule.ui
|
||||
package org.xtimms.psu.schedule.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
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.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import ritm.schedule.data.Exam
|
||||
import ritm.schedule.data.Timetable
|
||||
import ritm.schedule.data.longLabel
|
||||
import ritm.schedule.data.parseDate
|
||||
import ritm.schedule.data.plural
|
||||
import ritm.schedule.data.shortWeekday
|
||||
import ritm.schedule.data.today
|
||||
import ritm.schedule.data.weekdayName
|
||||
import ritm.schedule.ui.theme.card
|
||||
import org.xtimms.psu.schedule.data.Exam
|
||||
import org.xtimms.psu.schedule.data.Timetable
|
||||
import org.xtimms.psu.schedule.data.longLabel
|
||||
import org.xtimms.psu.schedule.data.parseDate
|
||||
import org.xtimms.psu.schedule.data.plural
|
||||
import org.xtimms.psu.schedule.data.shortWeekday
|
||||
import org.xtimms.psu.schedule.data.today
|
||||
import org.xtimms.psu.schedule.data.weekdayName
|
||||
import org.xtimms.psu.schedule.ui.theme.card
|
||||
import java.time.LocalDate
|
||||
import java.time.temporal.ChronoUnit
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
@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.foundation.selection.toggleable
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import ritm.schedule.ui.theme.card
|
||||
import ritm.schedule.ui.theme.page
|
||||
import org.xtimms.psu.schedule.ui.theme.card
|
||||
import android.Manifest
|
||||
import android.os.Build
|
||||
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.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material.icons.Icons
|
||||
@@ -38,12 +36,11 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import ritm.schedule.BuildConfig
|
||||
import ritm.schedule.data.ThemeMode
|
||||
import ritm.schedule.notify.Reminders
|
||||
import org.xtimms.psu.schedule.BuildConfig
|
||||
import org.xtimms.psu.schedule.data.ThemeMode
|
||||
import org.xtimms.psu.schedule.notify.Reminders
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -1,15 +1,15 @@
|
||||
package ritm.schedule.ui
|
||||
package org.xtimms.psu.schedule.ui
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.lerp
|
||||
import ritm.schedule.data.Display
|
||||
import ritm.schedule.ui.theme.harmonizeWith
|
||||
import ritm.schedule.data.Lesson
|
||||
import ritm.schedule.data.Timetable
|
||||
import ritm.schedule.data.parseTime
|
||||
import org.xtimms.psu.schedule.data.Display
|
||||
import org.xtimms.psu.schedule.ui.theme.harmonizeWith
|
||||
import org.xtimms.psu.schedule.data.Lesson
|
||||
import org.xtimms.psu.schedule.data.Timetable
|
||||
import org.xtimms.psu.schedule.data.parseTime
|
||||
import java.time.LocalTime
|
||||
|
||||
/**
|
||||
@@ -1,18 +1,22 @@
|
||||
@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.automirrored.rounded.Assignment
|
||||
import androidx.compose.material.icons.rounded.Add
|
||||
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.material.icons.rounded.DateRange
|
||||
import androidx.compose.material.icons.rounded.Event
|
||||
import ritm.schedule.ui.theme.page
|
||||
import ritm.schedule.ui.theme.card
|
||||
import ritm.schedule.ui.theme.page
|
||||
import org.xtimms.psu.schedule.ui.theme.page
|
||||
import org.xtimms.psu.schedule.ui.theme.card
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
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.size
|
||||
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.shape.CircleShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
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.automirrored.rounded.MenuBook
|
||||
import androidx.compose.material.icons.rounded.Person
|
||||
import androidx.compose.material3.CircularWavyProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
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.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import ritm.schedule.data.Lesson
|
||||
import ritm.schedule.data.Timetable
|
||||
import ritm.schedule.data.longLabel
|
||||
import ritm.schedule.data.monday
|
||||
import ritm.schedule.data.parseDate
|
||||
import ritm.schedule.data.plural
|
||||
import ritm.schedule.data.shortWeekday
|
||||
import ritm.schedule.data.today
|
||||
import ritm.schedule.data.weekdayName
|
||||
import org.xtimms.psu.schedule.data.Lesson
|
||||
import org.xtimms.psu.schedule.data.Timetable
|
||||
import org.xtimms.psu.schedule.data.longLabel
|
||||
import org.xtimms.psu.schedule.data.monday
|
||||
import org.xtimms.psu.schedule.data.parseDate
|
||||
import org.xtimms.psu.schedule.data.plural
|
||||
import org.xtimms.psu.schedule.data.shortWeekday
|
||||
import org.xtimms.psu.schedule.data.today
|
||||
import org.xtimms.psu.schedule.data.weekdayName
|
||||
import java.time.LocalTime
|
||||
|
||||
/** Everything the loaded weeks say about one subject. */
|
||||
@@ -128,24 +128,31 @@ fun SubjectsScreen(vm: MainViewModel, nav: AppNav) {
|
||||
val t = timetable
|
||||
when {
|
||||
t == null && status.loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { LoadingIndicator() }
|
||||
t == null -> Empty("Предметов пока нет", "Выберите группу — предметы появятся из её расписания.", icon = Icons.AutoMirrored.Rounded.MenuBook, action = "Выбрать группу" to nav.openGroups)
|
||||
t.semester == null -> Empty("Предметы недоступны", "Для этого расписания даты не рассчитаны: откройте вкладку «Расписание».", icon = Icons.Rounded.EventBusy)
|
||||
else -> ContentColumn {
|
||||
t == null -> CenteredEmpty {
|
||||
Empty("Предметов пока нет", "Выберите группу — предметы появятся из её расписания.", icon = Icons.AutoMirrored.Rounded.MenuBook, action = "Выбрать группу" to nav.openGroups)
|
||||
}
|
||||
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 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()),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalItemSpacing = 12.dp,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.widthIn(max = 1200.dp).fillMaxSize(),
|
||||
) {
|
||||
item { WeekLoad(vm, t, subgroup, subjects.size) }
|
||||
item {
|
||||
item(span = StaggeredGridItemSpan.FullLine) { WeekLoad(vm, t, subgroup, subjects.size) }
|
||||
item(span = StaggeredGridItemSpan.FullLine) {
|
||||
Row(verticalAlignment = Alignment.Bottom, modifier = Modifier.padding(top = 8.dp)) {
|
||||
Heading("Все предметы", style = MaterialTheme.typography.titleLargeEmphasized, modifier = Modifier.weight(1f))
|
||||
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)
|
||||
|
||||
package ritm.schedule.ui
|
||||
package org.xtimms.psu.schedule.ui
|
||||
|
||||
import ritm.schedule.ui.theme.card
|
||||
import ritm.schedule.ui.theme.page
|
||||
import androidx.compose.ui.text.intl.LocaleList
|
||||
import androidx.compose.ui.text.style.Hyphens
|
||||
import androidx.compose.ui.text.style.LineBreak
|
||||
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.customActions
|
||||
import kotlin.math.abs
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
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.draw.drawBehind
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import ritm.schedule.data.Lesson
|
||||
import ritm.schedule.data.Timetable
|
||||
import ritm.schedule.data.longLabel
|
||||
import ritm.schedule.data.shortWeekday
|
||||
import ritm.schedule.data.today
|
||||
import ritm.schedule.data.weekdayName
|
||||
import org.xtimms.psu.schedule.data.Lesson
|
||||
import org.xtimms.psu.schedule.data.Timetable
|
||||
import org.xtimms.psu.schedule.data.longLabel
|
||||
import org.xtimms.psu.schedule.data.shortWeekday
|
||||
import org.xtimms.psu.schedule.data.today
|
||||
import org.xtimms.psu.schedule.data.weekdayName
|
||||
import java.time.LocalDate
|
||||
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)) {
|
||||
Text(
|
||||
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,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
@@ -1,4 +1,4 @@
|
||||
package ritm.schedule.ui.theme
|
||||
package org.xtimms.psu.schedule.ui.theme
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
@@ -15,7 +15,7 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import com.google.android.material.color.MaterialColors
|
||||
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).
|
||||
internal val Light = lightColorScheme(
|
||||
@@ -1,4 +1,4 @@
|
||||
package ritm.schedule.widget
|
||||
package org.xtimms.psu.schedule.widget
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -11,9 +11,8 @@ import androidx.glance.action.actionStartActivity
|
||||
import androidx.glance.action.clickable
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||
import ritm.schedule.app
|
||||
import org.xtimms.psu.schedule.app
|
||||
import androidx.glance.appwidget.provideContent
|
||||
import androidx.glance.background
|
||||
import androidx.glance.layout.Alignment
|
||||
import androidx.glance.layout.Column
|
||||
import androidx.glance.layout.Row
|
||||
@@ -24,7 +23,7 @@ import androidx.glance.layout.width
|
||||
import androidx.glance.text.FontWeight
|
||||
import androidx.glance.text.Text
|
||||
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. */
|
||||
class NextLessonWidget : GlanceAppWidget() {
|
||||
@@ -1,4 +1,4 @@
|
||||
package ritm.schedule.widget
|
||||
package org.xtimms.psu.schedule.widget
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -14,7 +14,6 @@ import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||
import androidx.glance.LocalSize
|
||||
import androidx.glance.appwidget.SizeMode
|
||||
import androidx.glance.appwidget.provideContent
|
||||
import androidx.glance.background
|
||||
import androidx.glance.layout.Alignment
|
||||
import androidx.glance.layout.Column
|
||||
import androidx.glance.layout.Row
|
||||
@@ -26,15 +25,15 @@ import androidx.glance.layout.padding
|
||||
import androidx.glance.text.FontWeight
|
||||
import androidx.glance.text.Text
|
||||
import androidx.glance.text.TextStyle
|
||||
import ritm.schedule.MainActivity
|
||||
import ritm.schedule.app
|
||||
import ritm.schedule.data.Lesson
|
||||
import ritm.schedule.data.longLabel
|
||||
import ritm.schedule.data.minutesLabel
|
||||
import ritm.schedule.data.nowTime
|
||||
import ritm.schedule.data.parseTime
|
||||
import ritm.schedule.data.today
|
||||
import ritm.schedule.data.weekdayName
|
||||
import org.xtimms.psu.schedule.MainActivity
|
||||
import org.xtimms.psu.schedule.app
|
||||
import org.xtimms.psu.schedule.data.Lesson
|
||||
import org.xtimms.psu.schedule.data.longLabel
|
||||
import org.xtimms.psu.schedule.data.minutesLabel
|
||||
import org.xtimms.psu.schedule.data.nowTime
|
||||
import org.xtimms.psu.schedule.data.parseTime
|
||||
import org.xtimms.psu.schedule.data.today
|
||||
import org.xtimms.psu.schedule.data.weekdayName
|
||||
import java.time.Duration
|
||||
import java.time.LocalDate
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
package ritm.schedule.widget
|
||||
package org.xtimms.psu.schedule.widget
|
||||
|
||||
import android.content.Context
|
||||
import ritm.schedule.app
|
||||
import ritm.schedule.data.Lesson
|
||||
import ritm.schedule.data.minutesLabel
|
||||
import ritm.schedule.data.nowTime
|
||||
import ritm.schedule.data.parseTime
|
||||
import ritm.schedule.data.shortWeekday
|
||||
import ritm.schedule.data.today
|
||||
import org.xtimms.psu.schedule.app
|
||||
import org.xtimms.psu.schedule.data.Lesson
|
||||
import org.xtimms.psu.schedule.data.minutesLabel
|
||||
import org.xtimms.psu.schedule.data.nowTime
|
||||
import org.xtimms.psu.schedule.data.parseTime
|
||||
import org.xtimms.psu.schedule.data.shortWeekday
|
||||
import org.xtimms.psu.schedule.data.today
|
||||
import java.time.Duration
|
||||
import java.time.LocalDate
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package ritm.schedule.widget
|
||||
package org.xtimms.psu.schedule.widget
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -12,9 +12,9 @@ import androidx.glance.appwidget.cornerRadius
|
||||
import androidx.glance.background
|
||||
import androidx.glance.material3.ColorProviders
|
||||
import androidx.glance.unit.ColorProvider
|
||||
import ritm.schedule.R
|
||||
import ritm.schedule.ui.theme.Dark
|
||||
import ritm.schedule.ui.theme.Light
|
||||
import org.xtimms.psu.schedule.R
|
||||
import org.xtimms.psu.schedule.ui.theme.Dark
|
||||
import org.xtimms.psu.schedule.ui.theme.Light
|
||||
|
||||
/**
|
||||
* 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 ritm.schedule.widget.NextLessonWidget
|
||||
import org.xtimms.psu.schedule.tile.LessonTileService
|
||||
import org.xtimms.psu.schedule.widget.NextLessonWidget
|
||||
import android.content.Context
|
||||
import androidx.glance.appwidget.updateAll
|
||||
import androidx.work.Constraints
|
||||
@@ -11,9 +11,9 @@ import androidx.work.NetworkType
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import ritm.schedule.app
|
||||
import ritm.schedule.notify.Reminders
|
||||
import ritm.schedule.widget.TodayWidget
|
||||
import org.xtimms.psu.schedule.app
|
||||
import org.xtimms.psu.schedule.notify.Reminders
|
||||
import org.xtimms.psu.schedule.widget.TodayWidget
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
@@ -8,7 +8,7 @@ import { join } from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
import { parseWeekRanges, parseTextDates } from "../shared/weeks.js";
|
||||
const run = promisify(execFile);
|
||||
export const PARSER_VERSION = 7;
|
||||
export const PARSER_VERSION = 8;
|
||||
const days = [
|
||||
"понедельник",
|
||||
"вторник",
|
||||
@@ -24,10 +24,13 @@ const clean = (value) =>
|
||||
.replace(/[\t ]+/g, " ")
|
||||
.replace(/\s*\n\s*/g, "\n")
|
||||
.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(
|
||||
/\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) =>
|
||||
m[0]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
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";
|
||||
const fixture = (name) =>
|
||||
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("Сессии: отдельные колонки времени, аудитории и преподавателя", () => {
|
||||
const s = parseDocx(fixture("fit"), { name: "26-ИТзс.docx" }),
|
||||
l = s.lessons[0];
|
||||
|
||||
Reference in New Issue
Block a user