feat: Implement week grid layout for timetable display

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

View File

@@ -98,8 +98,9 @@ DOMAIN=schedule.example.by docker compose --profile https up -d --build
## Android-приложение
В папке `android/` — нативный клиент на 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 по дням недели.
- Предметы: пары по дням текущей недели; у каждого предмета — сокращение как в недельной сетке, следующее занятие с аудиторией, преподаватели и сколько пар на этой неделе. У каждого предмета свой постоянный цвет во всех разделах.

View File

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

View File

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

View File

@@ -1,18 +1,27 @@
package ritm.schedule
package org.xtimms.psu.schedule
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.Orientation
import kotlin.coroutines.cancellation.CancellationException
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.rememberTransition
import androidx.compose.animation.core.SeekableTransitionState
import androidx.activity.compose.PredictiveBackHandler
import androidx.compose.foundation.layout.width
import androidx.compose.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,
)
}

View File

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

View File

@@ -1,5 +1,6 @@
package ritm.schedule.data
package org.xtimms.psu.schedule.data
import kotlinx.coroutines.delay
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.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>(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
package ritm.schedule.notify
package org.xtimms.psu.schedule.notify
import ritm.schedule.data.hideKey
import org.xtimms.psu.schedule.data.hideKey
import android.Manifest
import android.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+ ("Будильники и напоминания"). */

View File

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

View File

@@ -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. */

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
package ritm.schedule.ui
package org.xtimms.psu.schedule.ui
import androidx.compose.runtime.setValue
import androidx.compose.runtime.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)

View File

@@ -1,5 +1,12 @@
package ritm.schedule.ui
package org.xtimms.psu.schedule.ui
import androidx.compose.ui.Modifier
import androidx.compose.material3.MaterialTheme
import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridItemScope
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.layout.Box
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.ContentTransform
import androidx.compose.animation.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

View File

@@ -1,10 +1,7 @@
package ritm.schedule.ui
package org.xtimms.psu.schedule.ui
import androidx.compose.foundation.layout.RowScope
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.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

View File

@@ -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. */

View File

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

View File

@@ -1,7 +1,18 @@
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
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),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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