diff --git a/README.md b/README.md index 6fb5c64..74864c4 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,7 @@ DOMAIN=schedule.example.by docker compose --profile https up -d --build - Планы: задания и заметки по дням — к занятию или общие, без привязки к паре, на выбранный день, просроченные сверху, «+» прикрепляет задание к ближайшему занятию. Три макета, как в School Planner: «Повестка дня» (все дни с липкими заголовками и полосой недели, которая следует за списком), «Список» (только дни с делами) и «Доска» (колонки дней). В настройках вида — пары дня и выполненные задания. - Календарь: месяц с отметками дней с парами и с планами, зелёные недели подсвечены; под ним лист с планами и парами выбранного дня. Лист можно поднять — месяц сворачивается до выбранной недели. - Обратный отсчёт до сессии в «Обзоре»: дни до ближайшего экзамена или зачёта из `/api/exams` (консультации — только в списке), по нажатию — вся сессия. Пока университет не опубликовал сессию, отсчёт идёт до конца семестра. Экзамены кэшируются на устройстве. +- **Изменения в расписании.** Как в PWA: когда университет перезаливает документ группы, приложение сравнивает новое расписание с копией на устройстве. В «Обзоре» появляется карточка «Расписание обновлено · N изменений» с кнопкой «Показать», а в списке видно, что изменилось, что добавлено и что удалено, например «Аудитория: 374 → 375». Карточка показывается до просмотра или закрытия, последний список остаётся в настройках («Последние изменения»). Сравнение срабатывает и при фоновом обновлении. Считается только новая версия того же файла: смена версии парсера или переход на другой документ изменениями не считаются. Сравниваются строки Word по дням недели и разовые занятия со своей датой, а не каждая рассчитанная дата. - Задания и заметки к паре: из карточки занятия — к этой паре или к следующей паре того же предмета. Задания отмечаются выполненными, просроченные подсвечиваются; список — в «Обзоре», по предмету — в «Предметах», точка на блоке в недельной сетке. Хранятся только на устройстве (`notes.json`) и привязаны к группе. - Посещаемость: «был» или «пропустил» в карточке занятия и одним касанием в «Обзоре» для прошедших сегодня пар; статистика по каждому предмету в «Предметах». Хранится на устройстве (`attendance.json`). - Скрытие занятий: «Скрыть такие занятия» в карточке занятия убирает предмет с этим типом занятия из расписания, обзора, виджетов и напоминаний; вернуть можно в настройках. @@ -191,6 +192,7 @@ cd android - **Онбординг:** приветствие (три стеклянные «капли» сливаются в одну), затем группа, подгруппа (только если группа делится на подгруппы) и напоминания. Разрешение на уведомления спрашивается на последнем шаге. - **Стеклянная панель вкладок:** три раздела — «Обзор», «Расписание», «Планы» — и поиск отдельной круглой кнопкой справа (роль `.search`). Нажатие превращает кнопку в поле ввода над клавиатурой, как в приложениях Apple (`tabViewSearchActivation(.searchTabSelection)`). «Календарь» открывается кнопкой в «Планах», «Предметы» — карточкой в «Обзоре». На iPad панель превращается в боковое меню. Группа и настройки открываются из панели «Обзора»; тема меняется сразу во всех окнах, включая открытые настройки. - **Обзор:** неделя (сколько пар осталось, чип на каждый день), обратный отсчёт до сессии или конца семестра, следующие занятия с отсчётом «Через 20 мин», посещаемость одним касанием, задания и ближайший учебный день. На iPad рядом показывается недельная сетка. +- **Изменения в расписании:** как в Android и PWA — карточка «Расписание обновлено · N изменений» в «Обзоре» после перезаливки документа, по «Показать» — лист с изменёнными, добавленными и удалёнными занятиями; последний список — в настройках. Сравнение срабатывает и при фоновом обновлении. - **Расписание:** дни листаются в сторону под плавающей стеклянной лентой недели. На зелёных неделях стекло окрашено зелёным. Есть недельная сетка по часам и строки Word, если семестр не найден. В подробностях пары: время по звонкам, посещаемость, задания, исходная запись Word и скрытие таких занятий. - **Планы:** три макета — повестка дня с лентой недели, список и доска. Можно показывать пары дня и выполненные задания. Кнопка «+» — стеклянная. - **Календарь:** месяц с отметками пар и планов, зелёные недели подсвечены; ниже — план выбранного дня. diff --git a/android/app/src/main/kotlin/org/xtimms/psu/schedule/data/Changes.kt b/android/app/src/main/kotlin/org/xtimms/psu/schedule/data/Changes.kt new file mode 100644 index 0000000..e5d066c --- /dev/null +++ b/android/app/src/main/kotlin/org/xtimms/psu/schedule/data/Changes.kt @@ -0,0 +1,99 @@ +package org.xtimms.psu.schedule.data + +import kotlinx.serialization.Serializable + +// What changed for the group when the university republished its document, as the PWA reports it +// (shared/lessons.js diffSchedules): the new timetable is compared with the copy on the device. + +/** A Word row of the timetable: a weekday's lesson, or a lesson on its own date. */ +@Serializable +data class ChangedLesson(val day: Int, val date: String? = null, val lesson: Lesson) + +@Serializable +data class FieldChange(val label: String, val from: String, val to: String) + +@Serializable +data class LessonChange(val before: ChangedLesson, val after: ChangedLesson, val fields: List) + +@Serializable +data class ScheduleChanges( + val document: String, + val group: String, + val name: String, + /** When the change was found, ISO. */ + val at: String, + /** Dates of the two versions of the file, as the server sends them. */ + val from: String? = null, + val to: String? = null, + val added: List = emptyList(), + val removed: List = emptyList(), + val changed: List = emptyList(), + /** The card on the overview was opened or closed. */ + val seen: Boolean = false, +) { + val count get() = added.size + removed.size + changed.size +} + +// The server's lesson key starts with the lesson's own date when Word prints one (shared/lessons.js). +private val DATED = Regex("^\\d{4}-\\d{2}-\\d{2}\\|") + +/** + * The timetable's rows: the weekly Word rows, plus lessons printed with their own date. Dates + * computed from week marks are not compared one by one, or one changed mark would read as a + * dozen changes. + */ +fun rowsOf(t: Timetable): List { + val weekly = t.weekly.flatMap { d -> d.lessons.map { ChangedLesson(d.day, null, it) } } + val dated = t.days + .flatMap { d -> d.lessons.filter { DATED.containsMatchIn(it.key) }.map { ChangedLesson(d.weekday, d.date, it) } } + .distinctBy { it.lesson.key } + return weekly + dated +} + +private fun norm(s: String) = s.lowercase().replace(Regex("\\s+"), " ").trim() + +// Looser identity for pairing a lesson whose day, time or week mark changed. +private fun looseKey(l: Lesson) = listOf(norm(l.subject), l.variant, l.subgroup.orEmpty(), norm(l.type)).joinToString("|") + +private fun whenLabel(row: ChangedLesson) = + row.date?.let { parseDate(it).let { d -> "${d.weekdayName()}, ${d.longLabel()}" } } ?: dayName(row.day) + +private fun fields(a: ChangedLesson, b: ChangedLesson): List { + val shown = { s: String -> s.ifBlank { "не указано" } } + return listOfNotNull( + ("День" to (whenLabel(a) to whenLabel(b))), + ("Начало" to (a.lesson.start to b.lesson.start)), + ("Окончание" to (a.lesson.end.orEmpty() to b.lesson.end.orEmpty())), + ("Недели" to (a.lesson.condition to b.lesson.condition)), + ("Вид занятия" to (a.lesson.type to b.lesson.type)), + ("Преподаватель" to (a.lesson.teachers.joinToString(", ") to b.lesson.teachers.joinToString(", "))), + ("Аудитория" to (a.lesson.rooms.joinToString(", ") to b.lesson.rooms.joinToString(", "))), + ).filter { (_, values) -> values.first.trim() != values.second.trim() } + .map { (label, values) -> FieldChange(label, shown(values.first), shown(values.second)) } +} + +/** Added, removed and changed rows between two versions of the group's timetable. */ +fun diffTimetables(before: Timetable, after: Timetable): Triple, List, List> { + val old = rowsOf(before).groupByTo(LinkedHashMap()) { it.lesson.key } + val changed = mutableListOf() + val unmatched = mutableListOf() + for (row in rowsOf(after)) { + val was = old[row.lesson.key]?.removeFirstOrNull() + if (was == null) unmatched += row + else fields(was, row).takeIf { it.isNotEmpty() }?.let { changed += LessonChange(was, row, it) } + } + val left = old.values.flatten().toMutableList() + // Pair the rest by subject on the same day first, then by subject alone (moved to another day). + for (sameDay in listOf(true, false)) + for (row in unmatched.toList()) { + val index = left.indexOfFirst { + looseKey(it.lesson) == looseKey(row.lesson) && (!sameDay || (it.date ?: it.day.toString()) == (row.date ?: row.day.toString())) + } + if (index < 0) continue + val was = left.removeAt(index) + unmatched.remove(row) + changed += LessonChange(was, row, fields(was, row)) + } + val order = compareBy({ it.date.orEmpty() }, { it.day }, { it.lesson.start }) + return Triple(unmatched.sortedWith(order), left.sortedWith(order), changed.sortedWith(compareBy(order) { it.after })) +} diff --git a/android/app/src/main/kotlin/org/xtimms/psu/schedule/data/Repository.kt b/android/app/src/main/kotlin/org/xtimms/psu/schedule/data/Repository.kt index 60e50d6..cc1bf1e 100644 --- a/android/app/src/main/kotlin/org/xtimms/psu/schedule/data/Repository.kt +++ b/android/app/src/main/kotlin/org/xtimms/psu/schedule/data/Repository.kt @@ -22,6 +22,49 @@ class Repository(context: Context, val api: Api, private val settings: Settings) private val _exams = MutableStateFlow(readExams()) val exams: StateFlow = _exams + // What changed when the university republished a document, per document and group. + private val changesCache = File(context.filesDir, "changes.json") + private val _changes = MutableStateFlow(readChanges()) + val changes: StateFlow> = _changes + + private fun readChanges(): List = + runCatching { json.decodeFromString>(changesCache.readText()) }.getOrDefault(emptyList()) + + private fun writeChanges(list: List) { + _changes.value = list + runCatching { changesCache.writeText(json.encodeToString(kotlinx.serialization.builtins.ListSerializer(ScheduleChanges.serializer()), list)) } + } + + /** + * Compares a newer version of the same document with the copy on the device, as the PWA + * does: only a republished file counts, not a new parser or another document of the group. + */ + private fun detectChanges(previous: Timetable?, fresh: Timetable) { + if (previous == null || previous.document.id != fresh.document.id || previous.group != fresh.group) return + val was = previous.document.modified ?: return + val now = fresh.document.modified ?: return + if (was == now) return + val (added, removed, changed) = diffTimetables(previous, fresh) + if (added.isEmpty() && removed.isEmpty() && changed.isEmpty()) return + val record = ScheduleChanges( + document = fresh.document.id, + group = fresh.group, + name = fresh.document.name, + at = java.time.Instant.now().toString(), + from = was, + to = now, + added = added, + removed = removed, + changed = changed, + ) + writeChanges((listOf(record) + _changes.value.filter { it.document != record.document || it.group != record.group }).take(12)) + } + + /** The overview's card was opened or closed: the record stays for "Последние изменения". */ + suspend fun markChangesSeen(record: ScheduleChanges) = withContext(Dispatchers.IO) { + writeChanges(_changes.value.map { if (it.document == record.document && it.group == record.group) it.copy(seen = true) else it }) + } + private fun readExams(): ExamsResponse? = runCatching { json.decodeFromString(examsCache.readText()) }.getOrNull() @@ -40,6 +83,8 @@ class Repository(context: Context, val api: Api, private val settings: Settings) attempt { val from = today().monday() val fresh = api.timetable(group.docId, group.group, from.toString(), span) + // Also when the background sync finds the new version: the card waits for the student. + detectChanges(_timetable.value, fresh) cache.writeText(json.encodeToString(Timetable.serializer(), fresh)) _timetable.value = fresh // Exams come from the whole catalog index; their failure never fails the timetable. diff --git a/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/Changes.kt b/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/Changes.kt new file mode 100644 index 0000000..c510d26 --- /dev/null +++ b/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/Changes.kt @@ -0,0 +1,185 @@ +package org.xtimms.psu.schedule.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowForward +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.Difference +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import org.xtimms.psu.schedule.data.ChangedLesson +import org.xtimms.psu.schedule.data.LessonChange +import org.xtimms.psu.schedule.data.MINSK +import org.xtimms.psu.schedule.data.ScheduleChanges +import org.xtimms.psu.schedule.data.dayName +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.ui.theme.card +import java.time.Instant +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter + +/** "3 изменения". */ +fun changesLabel(n: Int) = "$n ${plural(n, "изменение", "изменения", "изменений")}" + +/** The date of a file version ("Mon, 19 Oct 2026 08:00:00 GMT") or of a check (ISO), in Minsk. */ +fun changeDate(value: String?): String? = value?.let { + runCatching { ZonedDateTime.parse(it, DateTimeFormatter.RFC_1123_DATE_TIME).toInstant() } + .recoverCatching { Instant.parse(value) } + .getOrNull() + ?.atZone(MINSK)?.toLocalDate()?.longLabel() +} + +/** + * The overview's card after the university republished the group's document: how many lessons + * changed, "Показать" for the list, and a close button. Both mark the record as seen. + */ +@Composable +fun ChangesCard(record: ScheduleChanges, onShow: () -> Unit, onClose: () -> Unit) { + Surface( + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.fillMaxWidth(), + ) { + Row(Modifier.padding(start = 20.dp, end = 8.dp, top = 16.dp, bottom = 16.dp), verticalAlignment = Alignment.Top) { + Surface(shape = CircleShape, color = MaterialTheme.colorScheme.secondary, modifier = Modifier.size(40.dp)) { + Icon(Icons.Rounded.Difference, contentDescription = null, modifier = Modifier.padding(8.dp)) + } + Spacer(Modifier.width(16.dp)) + Column(Modifier.weight(1f).semantics(mergeDescendants = true) {}, verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text("Расписание обновлено · ${changesLabel(record.count)}", style = MaterialTheme.typography.titleSmallEmphasized) + Text( + "Для ${record.group}" + (changeDate(record.to)?.let { " · файл от $it" } ?: ""), + style = MaterialTheme.typography.bodyMedium, + ) + Button(onClick = onShow, modifier = Modifier.padding(top = 8.dp)) { Text("Показать") } + } + IconButton(onClick = onClose, modifier = Modifier.align(Alignment.Top)) { + Icon(Icons.Rounded.Close, contentDescription = "Скрыть сообщение об изменениях") + } + } + } +} + +/** The list behind the card: changed, added and removed lessons, as in the PWA. */ +@Composable +fun ChangesSheet(record: ScheduleChanges) { + Column( + Modifier.verticalScroll(rememberScrollState()).padding(start = 24.dp, end = 24.dp, bottom = 32.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Heading("Изменения в расписании", style = MaterialTheme.typography.headlineSmallEmphasized) + Text( + "${record.group} · ${record.name.replace(Regex("\\.docx?$", RegexOption.IGNORE_CASE), "")}" + + (changeDate(record.to)?.let { " · файл от $it" } ?: "") + + ". Сравнение с прошлой версией, сохранённой на этом устройстве.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (record.changed.isNotEmpty()) { + Heading("Изменено · ${record.changed.size}", modifier = Modifier.padding(top = 8.dp)) + record.changed.forEach { ChangedRow(it) } + } + if (record.added.isNotEmpty()) { + Heading("Добавлено · ${record.added.size}", modifier = Modifier.padding(top = 8.dp)) + record.added.forEach { row -> + ChangeItem(row, details = listOf(row.lesson.type, row.lesson.teachers.joinToString(", "), row.lesson.rooms.joinToString(", ") { "ауд. $it" }, row.lesson.condition)) + } + } + if (record.removed.isNotEmpty()) { + Heading("Удалено · ${record.removed.size}", modifier = Modifier.padding(top = 8.dp)) + record.removed.forEach { row -> ChangeItem(row, removed = true, details = listOf(row.lesson.type, row.lesson.condition)) } + } + Text( + "Сверяйтесь с документом Word: при сомнениях он главнее.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp), + ) + } +} + +private fun whenText(row: ChangedLesson) = + (row.date?.let { parseDate(it).let { d -> "${d.shortWeekday()}, ${d.longLabel()}" } } ?: dayName(row.day)) + " · " + row.lesson.start + +@Composable +private fun ChangedRow(change: LessonChange) { + Surface(shape = MaterialTheme.shapes.large, color = MaterialTheme.colorScheme.card, modifier = Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(whenText(change.after), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary) + Text(change.after.lesson.subject, style = MaterialTheme.typography.titleMediumEmphasized) + change.fields.forEach { f -> + Row(verticalAlignment = Alignment.CenterVertically) { + Text("${f.label}: ", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text( + f.from, + style = MaterialTheme.typography.bodyMedium.copy(textDecoration = TextDecoration.LineThrough), + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f, fill = false), + ) + Icon( + Icons.AutoMirrored.Rounded.ArrowForward, + contentDescription = "на", + modifier = Modifier.padding(horizontal = 6.dp).size(16.dp), + ) + Text(f.to, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f, fill = false)) + } + } + if (change.fields.isEmpty()) Text( + "Изменился текст ячейки Word", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun ChangeItem(row: ChangedLesson, details: List, removed: Boolean = false) { + Surface( + shape = MaterialTheme.shapes.large, + color = if (removed) MaterialTheme.colorScheme.surfaceContainer else MaterialTheme.colorScheme.card, + modifier = Modifier.fillMaxWidth(), + ) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + whenText(row), + style = MaterialTheme.typography.labelLarge, + color = if (removed) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.primary, + ) + Text( + row.lesson.subject, + style = MaterialTheme.typography.titleMediumEmphasized.let { + if (removed) it.copy(textDecoration = TextDecoration.LineThrough) else it + }, + color = if (removed) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface, + ) + details.filter { it.isNotBlank() }.joinToString(" · ").takeIf { it.isNotEmpty() }?.let { + Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } +} diff --git a/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/HomeScreen.kt b/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/HomeScreen.kt index b738f32..30774cf 100644 --- a/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/HomeScreen.kt +++ b/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/HomeScreen.kt @@ -131,6 +131,8 @@ fun HomeScreen(vm: MainViewModel, nav: AppNav) { var details by remember { mutableStateOf?>(null) } var editing by remember { mutableStateOf(null) } var session by remember { mutableStateOf(false) } + val changes by vm.changes.collectAsStateWithLifecycle() + var changesOpen by remember { mutableStateOf(false) } val subgroup = settings?.subgroup val clock = rememberClock() val date = today() @@ -209,6 +211,18 @@ fun HomeScreen(vm: MainViewModel, nav: AppNav) { // 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)) } + // The university republished the document: what changed for the group, until seen. + changes?.takeIf { !it.seen }?.let { record -> + item(key = "changes") { + AnimatedItem { + ChangesCard( + record, + onShow = { changesOpen = true; vm.markChangesSeen(record) }, + onClose = { vm.markChangesSeen(record) }, + ) + } + } + } 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) } } @@ -237,6 +251,9 @@ fun HomeScreen(vm: MainViewModel, nav: AppNav) { } } } + if (changesOpen) changes?.let { record -> + ModalBottomSheet(onDismissRequest = { changesOpen = false }) { ChangesSheet(record) } + } if (session) timetable?.let { t -> val exams = upcomingExams(vm, t) ModalBottomSheet(onDismissRequest = { session = false }) { SessionSheet(exams) } diff --git a/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/MainViewModel.kt b/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/MainViewModel.kt index 5f16d88..67219d4 100644 --- a/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/MainViewModel.kt +++ b/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/MainViewModel.kt @@ -39,6 +39,16 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { /** The open group's exams; null until the first answer, then cached on the device. */ val exams: StateFlow = app.repository.exams + /** What changed in the open group's document when the university last republished it. */ + val changes: StateFlow = + combine(app.repository.changes, app.repository.timetable) { list, t -> + t?.let { list.firstOrNull { it.document == t.document.id && it.group == t.group } } + }.stateIn(viewModelScope, SharingStarted.Eagerly, null) + + fun markChangesSeen(record: org.xtimms.psu.schedule.data.ScheduleChanges) = viewModelScope.launch { + if (!record.seen) repository.markChangesSeen(record) + } + /** Tasks and notes of the open group. */ val notes: StateFlow> = combine(app.notes.notes, app.settings.flow) { list, s -> val key = s.current?.let(::groupKey) diff --git a/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/SettingsScreen.kt b/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/SettingsScreen.kt index 75ce87e..394b567 100644 --- a/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/SettingsScreen.kt +++ b/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/SettingsScreen.kt @@ -33,6 +33,9 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.foundation.clickable +import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -47,6 +50,8 @@ import org.xtimms.psu.schedule.notify.Reminders fun SettingsScreen(vm: MainViewModel, onBack: () -> Unit) { val settings by vm.settings.collectAsStateWithLifecycle() val timetable by vm.timetable.collectAsStateWithLifecycle() + val changes by vm.changes.collectAsStateWithLifecycle() + var changesOpen by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(false) } val s = settings ?: return val context = LocalContext.current val permission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> @@ -155,6 +160,24 @@ fun SettingsScreen(vm: MainViewModel, onBack: () -> Unit) { val doc = timetable?.document Info("Документ", doc?.name ?: "Группа не выбрана") timetable?.semester?.let { Info("Семестр", it.label) } + changes?.let { record -> + // The last republication of the document, also after its card was closed. + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().clip(MaterialTheme.shapes.medium) + .clickable(onClickLabel = "Показать изменения") { changesOpen = true }, + ) { + Column(Modifier.weight(1f)) { + Text("Последние изменения", style = MaterialTheme.typography.bodyLarge) + Text( + changesLabel(record.count) + (changeDate(record.at)?.let { " · найдены $it" } ?: ""), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Icon(Icons.AutoMirrored.Rounded.KeyboardArrowRight, contentDescription = null) + } + } Info("Сервер", BuildConfig.API_URL) Text( "Расписание берётся из публичного каталога ПГУ и пересчитывается по таблице белых и зелёных недель. " + @@ -166,6 +189,9 @@ fun SettingsScreen(vm: MainViewModel, onBack: () -> Unit) { } } } + if (changesOpen) changes?.let { record -> + androidx.compose.material3.ModalBottomSheet(onDismissRequest = { changesOpen = false }) { ChangesSheet(record) } + } } @Composable diff --git a/ios/PSUSchedule/AppModel.swift b/ios/PSUSchedule/AppModel.swift index c82ef35..fdfa45c 100644 --- a/ios/PSUSchedule/AppModel.swift +++ b/ios/PSUSchedule/AppModel.swift @@ -24,6 +24,8 @@ final class AppModel { private(set) var exams: ExamsResponse? private(set) var allNotes: [Note] private(set) var allMarks: [Mark] + /** What changed when the university republished a document, per document and group. */ + private(set) var allChanges: [ScheduleChanges] private(set) var status = Status() /** Moves every half minute while the app is open: the clock "Идёт · ещё 20 мин" counts from. */ private(set) var now = Date() @@ -39,6 +41,7 @@ final class AppModel { exams = Storage.read(ExamsResponse.self, "exams.json") allNotes = Storage.read([Note].self, "notes.json") ?? [] allMarks = Storage.read([Mark].self, "attendance.json") ?? [] + allChanges = Storage.read([ScheduleChanges].self, "changes.json") ?? [] Task { [weak self] in while !Task.isCancelled { try? await Task.sleep(for: .seconds(30)) @@ -62,6 +65,21 @@ final class AppModel { t.slots(on: date, subgroup: subgroup, hidden: hidden) } + /** The open group's last republished document: what changed for it. */ + var changes: ScheduleChanges? { + guard let t = timetable else { return nil } + return allChanges.first { $0.document == t.document.id && $0.group == t.group } + } + + /** The overview's card was opened or closed: the record stays for "Последние изменения". */ + func markChangesSeen(_ record: ScheduleChanges) { + guard !record.seen else { return } + for i in allChanges.indices where allChanges[i].document == record.document && allChanges[i].group == record.group { + allChanges[i].seen = true + } + Storage.write(allChanges, "changes.json") + } + /** Upcoming exams of the open group, soonest first; the cache of another group is ignored. */ var upcomingExams: [Exam] { guard let t = timetable, let e = exams, e.group == t.group else { return [] } @@ -110,6 +128,11 @@ final class AppModel { status.loading = true do { let fresh = try await block() + // Also in a background refresh: the card waits for the student on the overview. + if let record = ScheduleChanges.between(timetable, fresh) { + allChanges = Array(([record] + allChanges.filter { $0.document != record.document || $0.group != record.group }).prefix(12)) + Storage.write(allChanges, "changes.json") + } timetable = fresh Storage.write(fresh, "timetable.json") lastFailed = nil diff --git a/ios/PSUSchedule/Views/ChangesView.swift b/ios/PSUSchedule/Views/ChangesView.swift new file mode 100644 index 0000000..5d2575d --- /dev/null +++ b/ios/PSUSchedule/Views/ChangesView.swift @@ -0,0 +1,114 @@ +import SwiftUI + +extension ScheduleChanges: Identifiable { + var id: String { "\(document)|\(group)|\(at.timeIntervalSince1970)" } +} + +/** + * The overview's card after the university republished the group's document: how many lessons + * changed, "Показать" for the list, and a close button. Both mark the record as seen. + */ +struct ChangesCard: View { + var record: ScheduleChanges + var onShow: () -> Void + var onClose: () -> Void + + var body: some View { + HStack(alignment: .top, spacing: 14) { + Image(systemName: "arrow.triangle.2.circlepath.doc.on.clipboard") + .font(.body.weight(.semibold)) + .foregroundStyle(Color.onBrand) + .frame(width: 40, height: 40) + .background(Color.brand, in: .circle) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 4) { + Text("Расписание обновлено · \(changesLabel(record.count))").font(.headline) + Text("Для \(record.group)" + (fileDateLabel(record.to).map { " · файл от \($0)" } ?? "")) + .font(.subheadline).foregroundStyle(.secondary) + Button("Показать", action: onShow) + .buttonStyle(.glassProminent) + .padding(.top, 6) + } + .frame(maxWidth: .infinity, alignment: .leading) + Button("Скрыть сообщение об изменениях", systemImage: "xmark", action: onClose) + .labelStyle(.iconOnly) + .buttonStyle(.glass) + .buttonBorderShape(.circle) + } + .card(padding: 18) + } +} + +/// The list behind the card: changed, added and removed lessons, as in the PWA. +struct ChangesSheet: View { + @Environment(\.dismiss) private var dismiss + var record: ScheduleChanges + + var body: some View { + NavigationStack { + List { + Section { + Text("\(record.group) · \(record.name.replacingOccurrences(of: #"\.docx?$"#, with: "", options: [.regularExpression, .caseInsensitive]))" + + (fileDateLabel(record.to).map { " · файл от \($0)" } ?? "") + + ". Сравнение с прошлой версией, сохранённой на этом устройстве.") + .font(.subheadline).foregroundStyle(.secondary) + } + if !record.changed.isEmpty { + Section("Изменено · \(record.changed.count)") { + ForEach(record.changed, id: \.self) { change in + VStack(alignment: .leading, spacing: 4) { + when(change.after) + Text(change.after.lesson.subject).font(.headline) + ForEach(change.fields, id: \.self) { f in + (Text("\(f.label): ").foregroundStyle(.secondary) + + Text(f.from).strikethrough().foregroundStyle(.secondary) + + Text(" → ").foregroundStyle(.secondary) + + Text(f.to)) + .font(.subheadline) + .accessibilityLabel("\(f.label): было \(f.from), стало \(f.to)") + } + if change.fields.isEmpty { + Text("Изменился текст ячейки Word").font(.subheadline).foregroundStyle(.secondary) + } + } + .padding(.vertical, 2) + } + } + } + if !record.added.isEmpty { + Section("Добавлено · \(record.added.count)") { + ForEach(record.added, id: \.self) { row in + item(row, details: [row.lesson.type, row.lesson.teachers.joined(separator: ", "), + row.lesson.rooms.map { "ауд. \($0)" }.joined(separator: ", "), row.lesson.condition]) + } + } + } + if !record.removed.isEmpty { + Section("Удалено · \(record.removed.count)") { + ForEach(record.removed, id: \.self) { row in + item(row, removed: true, details: [row.lesson.type, row.lesson.condition]) + } + } + } + Section {} footer: { Text("Сверяйтесь с документом Word: при сомнениях он главнее.") } + } + .navigationTitle("Изменения") + .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Готово", systemImage: "checkmark") { dismiss() } } } + } + } + + private func when(_ row: ChangedLesson) -> some View { + let day = row.date.flatMap { CivilDate($0) }.map { "\($0.shortWeekday), \($0.longLabel)" } ?? dayName(row.day) + return Text(day + " · " + row.lesson.start).font(.subheadline.weight(.semibold)).foregroundStyle(Color.brand) + } + + private func item(_ row: ChangedLesson, removed: Bool = false, details: [String]) -> some View { + VStack(alignment: .leading, spacing: 4) { + when(row).foregroundStyle(removed ? .secondary : Color.brand) + Text(row.lesson.subject).font(.headline).strikethrough(removed).foregroundStyle(removed ? .secondary : .primary) + let text = details.filter { !$0.trimmed.isEmpty }.joined(separator: " · ") + if !text.isEmpty { Text(text).font(.subheadline).foregroundStyle(.secondary) } + } + .padding(.vertical, 2) + } +} diff --git a/ios/PSUSchedule/Views/HomeView.swift b/ios/PSUSchedule/Views/HomeView.swift index c8b66eb..0161eaa 100644 --- a/ios/PSUSchedule/Views/HomeView.swift +++ b/ios/PSUSchedule/Views/HomeView.swift @@ -9,6 +9,7 @@ struct HomeView: View { @State private var details: LessonRef? @State private var editor: EditorRequest? @State private var session = false + @State private var changes: ScheduleChanges? var body: some View { NavigationStack { @@ -26,6 +27,7 @@ struct HomeView: View { .sheet(item: $details) { LessonDetails(ref: $0).presentationDetents([.medium, .large]) } .sheet(item: $editor) { NoteEditor(request: $0) } .sheet(isPresented: $session) { SessionSheet(exams: model.upcomingExams).presentationDetents([.medium, .large]) } + .sheet(item: $changes) { ChangesSheet(record: $0).presentationDetents([.medium, .large]) } } @ViewBuilder private var content: some View { @@ -75,6 +77,16 @@ struct HomeView: View { let next = (1...7).lazy.map { today.adding(days: $0) }.map { ($0, model.slots(t, on: $0)) }.first { !$0.1.isEmpty } return VStack(alignment: .leading, spacing: 16) { StatusBanner() + // The university republished the document: what changed for the group, until seen. + if let record = model.changes, !record.seen { + ChangesCard(record: record, onShow: { + changes = record + model.markChangesSeen(record) + }, onClose: { + withAnimation { model.markChangesSeen(record) } + }) + .transition(.opacity.combined(with: .scale(scale: 0.96))) + } WeekReport(timetable: t) SessionCard(timetable: t) { session = true } DayTitle(label: "Сегодня", date: today) diff --git a/ios/PSUSchedule/Views/SettingsView.swift b/ios/PSUSchedule/Views/SettingsView.swift index df6633b..c423f33 100644 --- a/ios/PSUSchedule/Views/SettingsView.swift +++ b/ios/PSUSchedule/Views/SettingsView.swift @@ -3,6 +3,7 @@ import SwiftUI struct SettingsView: View { @Environment(AppModel.self) private var model @Environment(\.dismiss) private var dismiss + @State private var changes: ScheduleChanges? var body: some View { let s = model.settings @@ -63,6 +64,13 @@ struct SettingsView: View { Section { LabeledContent("Документ", value: model.timetable?.document.name ?? "Группа не выбрана") if let sem = model.timetable?.semester { LabeledContent("Семестр", value: sem.label) } + // The last republication of the document, also after its card was closed. + if let record = model.changes { + Button { changes = record } label: { + LabeledContent("Последние изменения", value: changesLabel(record.count) + " · " + CivilDate(record.at).longLabel) + .foregroundStyle(.primary) + } + } LabeledContent("Сервер", value: model.api.base.absoluteString) } header: { Text("О расписании") @@ -73,6 +81,7 @@ struct SettingsView: View { .navigationTitle("Настройки") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Готово", systemImage: "checkmark") { dismiss() } } } + .sheet(item: $changes) { ChangesSheet(record: $0).presentationDetents([.medium, .large]) } } } diff --git a/ios/Shared/Changes.swift b/ios/Shared/Changes.swift new file mode 100644 index 0000000..a822fee --- /dev/null +++ b/ios/Shared/Changes.swift @@ -0,0 +1,151 @@ +import Foundation + +// What changed for the group when the university republished its document, as the PWA reports it +// (shared/lessons.js diffSchedules) and the Android client's Changes.kt: the new timetable is +// compared with the copy on the device. + +/// A Word row of the timetable: a weekday's lesson, or a lesson on its own date. +struct ChangedLesson: Codable, Hashable { + var day: Int + var date: String? + var lesson: Lesson +} + +struct FieldChange: Codable, Hashable { + var label: String + var from: String + var to: String +} + +struct LessonChange: Codable, Hashable { + var before: ChangedLesson + var after: ChangedLesson + var fields: [FieldChange] +} + +struct ScheduleChanges: Codable, Hashable { + var document: String + var group: String + var name: String + /// When the change was found. + var at: Date + /// Dates of the two versions of the file, as the server sends them. + var from: String? + var to: String? + var added: [ChangedLesson] = [] + var removed: [ChangedLesson] = [] + var changed: [LessonChange] = [] + /// The card on the overview was opened or closed. + var seen = false + + var count: Int { added.count + removed.count + changed.count } + + /** + * Compares a newer version of the same document with the copy on the device: only a + * republished file counts, not a new parser or another document of the group. + */ + static func between(_ previous: Timetable?, _ fresh: Timetable, now: Date = Date()) -> ScheduleChanges? { + guard let previous, previous.document.id == fresh.document.id, previous.group == fresh.group, + let was = previous.document.modified, let then = fresh.document.modified, was != then + else { return nil } + let (added, removed, changed) = diff(previous, fresh) + let record = ScheduleChanges( + document: fresh.document.id, group: fresh.group, name: fresh.document.name, at: now, + from: was, to: then, added: added, removed: removed, changed: changed + ) + return record.count > 0 ? record : nil + } +} + +/// "3 изменения". +func changesLabel(_ n: Int) -> String { "\(n) \(plural(n, "изменение", "изменения", "изменений"))" } + +/// The date of a file version as the server sends it ("Mon, 19 Oct 2026 08:00:00 GMT"). +func fileDateLabel(_ value: String?) -> String? { + guard let value else { return nil } + let f = DateFormatter() + f.locale = Locale(identifier: "en_US_POSIX") + f.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz" + return f.date(from: value).map { CivilDate($0).longLabel } +} + +/** + * The timetable's rows: the weekly Word rows, plus lessons printed with their own date (the + * server's key then starts with that date). Dates computed from week marks are not compared one + * by one, or one changed mark would read as a dozen changes. + */ +private func rows(_ t: Timetable) -> [ChangedLesson] { + let weekly = t.weekly.flatMap { d in d.lessons.map { ChangedLesson(day: d.day, date: nil, lesson: $0) } } + var seen = Set() + let dated = t.days.flatMap { d in + d.lessons.filter { $0.key.range(of: #"^\d{4}-\d{2}-\d{2}\|"#, options: .regularExpression) != nil } + .map { ChangedLesson(day: d.weekday, date: d.date, lesson: $0) } + }.filter { seen.insert($0.lesson.key).inserted } + return weekly + dated +} + +private func norm(_ s: String) -> String { + s.lowercased().split(whereSeparator: \.isWhitespace).joined(separator: " ") +} + +/// Looser identity for pairing a lesson whose day, time or week mark changed. +private func looseKey(_ l: Lesson) -> String { + [norm(l.subject), l.variant, l.subgroup ?? "", norm(l.type)].joined(separator: "|") +} + +private func whenLabel(_ row: ChangedLesson) -> String { + row.date.flatMap { CivilDate($0) }.map { "\($0.weekdayName), \($0.longLabel)" } ?? dayName(row.day) +} + +private func fields(_ a: ChangedLesson, _ b: ChangedLesson) -> [FieldChange] { + let pairs: [(String, String, String)] = [ + ("День", whenLabel(a), whenLabel(b)), + ("Начало", a.lesson.start, b.lesson.start), + ("Окончание", a.lesson.end ?? "", b.lesson.end ?? ""), + ("Недели", a.lesson.condition, b.lesson.condition), + ("Вид занятия", a.lesson.type, b.lesson.type), + ("Преподаватель", a.lesson.teachers.joined(separator: ", "), b.lesson.teachers.joined(separator: ", ")), + ("Аудитория", a.lesson.rooms.joined(separator: ", "), b.lesson.rooms.joined(separator: ", ")), + ] + let shown = { (s: String) in s.trimmed.isEmpty ? "не указано" : s } + return pairs.filter { $0.1.trimmed != $0.2.trimmed }.map { FieldChange(label: $0.0, from: shown($0.1), to: shown($0.2)) } +} + +private func order(_ a: ChangedLesson, _ b: ChangedLesson) -> Bool { + ((a.date ?? ""), a.day, a.lesson.start) < ((b.date ?? ""), b.day, b.lesson.start) +} + +/// Added, removed and changed rows between two versions of the group's timetable. +private func diff(_ before: Timetable, _ after: Timetable) -> ([ChangedLesson], [ChangedLesson], [LessonChange]) { + var old: [String: [ChangedLesson]] = [:] + var order0: [String] = [] + for row in rows(before) { + if old[row.lesson.key] == nil { order0.append(row.lesson.key) } + old[row.lesson.key, default: []].append(row) + } + var changed: [LessonChange] = [] + var unmatched: [ChangedLesson] = [] + for row in rows(after) { + if var list = old[row.lesson.key], !list.isEmpty { + let was = list.removeFirst() + old[row.lesson.key] = list + let f = fields(was, row) + if !f.isEmpty { changed.append(LessonChange(before: was, after: row, fields: f)) } + } else { + unmatched.append(row) + } + } + var left = order0.flatMap { old[$0] ?? [] } + // Pair the rest by subject on the same day first, then by subject alone (moved to another day). + for sameDay in [true, false] { + for row in unmatched { + guard let index = left.firstIndex(where: { + looseKey($0.lesson) == looseKey(row.lesson) && (!sameDay || ($0.date ?? "\($0.day)") == (row.date ?? "\(row.day)")) + }) else { continue } + let was = left.remove(at: index) + if let i = unmatched.firstIndex(of: row) { unmatched.remove(at: i) } + changed.append(LessonChange(before: was, after: row, fields: fields(was, row))) + } + } + return (unmatched.sorted(by: order), left.sorted(by: order), changed.sorted { order($0.after, $1.after) }) +}