feat: enhance group management and error handling in the app

- Added `forgetRecent` function in `Settings` to remove groups no longer recognized by the server from the quick-switch list.
- Updated `Status` data class in `MainViewModel` to include a `code` property for better error categorization and introduced `needsGroup` logic to determine if a group needs to be reselected.
- Modified `HomeScreen` and `ScheduleScreen` to display a "Выбрать группу" action in the banner when a group is not found, improving user experience during errors.
- Refactored `Banner` composable to support dynamic actions, allowing for more contextual responses to user errors.
This commit is contained in:
2026-10-21 00:51:14 +03:00
parent 67bb2ea6fb
commit 3d08d0cb70
6 changed files with 11081 additions and 10781 deletions

View File

@@ -241,6 +241,7 @@ DOMAIN=schedule.example.by docker compose --profile https up -d --build
- Планы: задания и заметки по дням — к занятию или общие, без привязки к паре, на выбранный день, просроченные сверху, «+» прикрепляет задание к ближайшему занятию. Три макета, как в School Planner: «Повестка дня» (все дни с липкими заголовками и полосой недели, которая следует за списком), «Список» (только дни с делами) и «Доска» (колонки дней). В настройках вида — пары дня и выполненные задания.
- Календарь: месяц с отметками дней с парами и с планами, зелёные недели подсвечены; под ним лист с планами и парами выбранного дня. Лист можно поднять — месяц сворачивается до выбранной недели.
- Обратный отсчёт до сессии в «Обзоре»: дни до ближайшего экзамена или зачёта из `/api/exams` (консультации — только в списке), по нажатию — вся сессия. Пока университет не опубликовал сессию, отсчёт идёт до конца семестра. Экзамены кэшируются на устройстве.
- **Группа, которой больше нет.** Если сервер отвечает `group_not_found` или `document_not_found` — группу разделили («24‑ИЯ(РА)» стала «24‑ИЯ (РА)и‑1»…), или документ ушёл из каталога, — плашка с текстом сервера предлагает «Выбрать группу». Сохранённая копия остаётся на экране, а устаревшая группа убирается из недавних.
- **Изменения в расписании.** Как в PWA: когда университет перезаливает документ группы, приложение сравнивает новое расписание с копией на устройстве. В «Обзоре» появляется карточка «Расписание обновлено · N изменений» с кнопкой «Показать», а в списке видно, что изменилось, что добавлено и что удалено, например «Аудитория: 374 → 375». Карточка показывается до просмотра или закрытия, последний список остаётся в настройках («Последние изменения»). Сравнение срабатывает и при фоновом обновлении (`SyncWorker`, раз в 6 часов). Если изменения нашлись в фоне, приходит уведомление «Расписание 23-АРХ изменилось» с первыми изменениями, например «Проектирование общественных зданий: аудитория 374 → 375». Уведомление локальное, сервер push для него не нужен. Его можно выключить в настройках («Изменения в расписании», по умолчанию включено), а на Android 13+ нужно разрешение на уведомления. Считается только новая версия того же файла: смена версии парсера или переход на другой документ изменениями не считаются. Сравниваются строки Word по дням недели и разовые занятия со своей датой, а не каждая рассчитанная дата.
- Задания и заметки к паре: из карточки занятия — к этой паре или к следующей паре того же предмета. Задания отмечаются выполненными, просроченные подсвечиваются; список — в «Обзоре», по предмету — в «Предметах», точка на блоке в недельной сетке. Хранятся только на устройстве (`notes.json`) и привязаны к группе.
- Посещаемость: «был» или «пропустил» в карточке занятия и одним касанием в «Обзоре» для прошедших сегодня пар; статистика по каждому предмету в «Предметах». Хранится на устройстве (`attendance.json`).

File diff suppressed because it is too large Load Diff

View File

@@ -126,6 +126,14 @@ class Settings(private val context: Context) {
prefs[Keys.recent] = json.encodeToString(next)
}
/** Drops a group the server no longer knows from the quick-switch list. */
suspend fun forgetRecent(group: SavedGroup) = context.store.edit { prefs ->
val recent = prefs[Keys.recent]?.let {
runCatching { json.decodeFromString<List<SavedGroup>>(it) }.getOrNull()
}.orEmpty()
prefs[Keys.recent] = json.encodeToString(recent.filterNot { it.docId == group.docId && it.group == group.group })
}
suspend fun setSubgroup(label: String?) = context.store.edit { prefs ->
val current = prefs[Keys.current]?.let { runCatching { json.decodeFromString<SavedGroup>(it) }.getOrNull() }
?: return@edit

View File

@@ -175,7 +175,7 @@ fun HomeScreen(vm: MainViewModel, nav: AppNav) {
}
},
)
status.message?.let { Banner(it, offline = status.offline, onClose = vm::dismissMessage, onRetry = if (status.offline) vm::retry else null) }
status.message?.let { Banner(it, offline = status.offline, onClose = vm::dismissMessage, onRetry = if (status.offline) vm::retry else null, action = if (status.needsGroup) "Выбрать группу" to nav.openGroups else null) }
val t = timetable
when {
t == null && status.loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { LoadingIndicator() }
@@ -185,7 +185,7 @@ fun HomeScreen(vm: MainViewModel, nav: AppNav) {
"Выберите группу — обзор дня и недели появится здесь.",
icon = Icons.Rounded.Groups,
// 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,
action = if (settings?.current != null && !status.needsGroup) "Повторить" to { vm.refresh(); Unit } else "Выбрать группу" to nav.openGroups,
)
}
t.semester == null -> Column(Modifier.padding(top = 8.dp)) {

View File

@@ -24,7 +24,13 @@ 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)
data class Status(val loading: Boolean = false, val message: String? = null, val offline: Boolean = false, val code: String? = null) {
/**
* The saved group is gone from its document — split into groups ("24-ИЯ(РА)" became
* "24-ИЯ (РА)и-1" …) or the document left the catalog: only picking the group again helps.
*/
val needsGroup get() = code == "group_not_found" || code == "document_not_found"
}
class MainViewModel(application: Application) : AndroidViewModel(application) {
private val app = application.app
@@ -118,13 +124,17 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
val result = block()
val error = result.exceptionOrNull()
val offline = (error as? ApiException)?.offline == true
val code = (error as? ApiException)?.code
lastFailed = if (error != null) block else null
_status.value = Status(
loading = false,
// 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,
code = code,
)
// A group the server no longer has stays out of the quick-switch list.
if (_status.value.needsGroup) app.settings.now().current?.let { app.settings.forgetRecent(it) }
SyncWorker.afterChange(getApplication())
return result.isSuccess
}

View File

@@ -137,7 +137,7 @@ 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, onRetry = if (status.offline) vm::retry else null) }
status.message?.let { Banner(it, offline = status.offline, onClose = vm::dismissMessage, onRetry = if (status.offline) vm::retry else null, action = if (status.needsGroup) "Выбрать группу" to nav.openGroups else null) }
when {
t == null && status.loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { LoadingIndicator() }
t == null -> CenteredEmpty {
@@ -338,7 +338,7 @@ private fun Planner(
} }
}
status.message?.let {
Box(Modifier.background(MaterialTheme.colorScheme.page)) { Banner(it, offline = status.offline, onClose = vm::dismissMessage, onRetry = if (status.offline) vm::retry else null) }
Box(Modifier.background(MaterialTheme.colorScheme.page)) { Banner(it, offline = status.offline, onClose = vm::dismissMessage, onRetry = if (status.offline) vm::retry else null, action = if (status.needsGroup) "Выбрать группу" to nav.openGroups else null) }
}
}
val timetable: @Composable (Modifier) -> Unit = {
@@ -710,19 +710,38 @@ private fun WordRows(t: Timetable, subgroup: String?, hidden: Set<String>, onOpe
}
@Composable
fun Banner(text: String, offline: Boolean, onClose: (() -> Unit)?, onRetry: (() -> Unit)? = null) {
fun Banner(
text: String,
offline: Boolean,
onClose: (() -> Unit)?,
onRetry: (() -> Unit)? = null,
/** A way out the message asks for, such as "Выбрать группу". */
action: Pair<String, () -> Unit>? = null,
) {
Surface(
shape = MaterialTheme.shapes.large,
color = if (offline) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.errorContainer,
contentColor = if (offline) MaterialTheme.colorScheme.onSecondaryContainer else MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
) {
Row(Modifier.padding(start = 16.dp, end = 4.dp, top = 8.dp, bottom = 8.dp), verticalAlignment = Alignment.CenterVertically) {
val buttons: @Composable () -> Unit = {
if (onRetry != null) TextButton(onClick = onRetry) { Text("Повторить") }
action?.let { (label, onClick) -> TextButton(onClick = onClick) { Text(label) } }
if (onClose != null) TextButton(onClick = onClose) { Text("OK") }
}
// With an action the message needs the whole width; the buttons go under it.
if (action != null) Column(Modifier.padding(start = 16.dp, end = 4.dp, top = 12.dp, bottom = 4.dp)) {
Row {
Icon(Icons.Rounded.ErrorOutline.takeIf { !offline } ?: Icons.Rounded.CloudOff, contentDescription = null)
Spacer(Modifier.width(12.dp))
Text(text, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f).padding(end = 12.dp))
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { buttons() }
} else Row(Modifier.padding(start = 16.dp, end = 4.dp, top = 8.dp, bottom = 8.dp), verticalAlignment = Alignment.CenterVertically) {
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") }
buttons()
}
}
}