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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user