feat: implement changes tracking for university documents

- Added a new data structure `ScheduleChanges` to represent changes when a university document is republished.
- Implemented methods to read and write changes to a local JSON file.
- Enhanced the `Repository` class to detect changes between the current and fresh timetable versions.
- Updated the `MainViewModel` to expose changes to the UI and handle marking changes as seen.
- Created UI components for displaying changes in both Home and Settings screens.
- Added support for changes in iOS views, including a new `ChangesCard` and `ChangesSheet`.
- Implemented logic to compare timetable versions and identify added, removed, and changed lessons.
This commit is contained in:
2026-10-19 23:57:10 +03:00
parent 68f9b95090
commit f8984e72d9
12 changed files with 693 additions and 0 deletions

View File

@@ -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 и скрытие таких занятий.
- **Планы:** три макета — повестка дня с лентой недели, список и доска. Можно показывать пары дня и выполненные задания. Кнопка «+» — стеклянная.
- **Календарь:** месяц с отметками пар и планов, зелёные недели подсвечены; ниже — план выбранного дня.

View File

@@ -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<FieldChange>)
@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<ChangedLesson> = emptyList(),
val removed: List<ChangedLesson> = emptyList(),
val changed: List<LessonChange> = 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<ChangedLesson> {
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<FieldChange> {
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<ChangedLesson>, List<ChangedLesson>, List<LessonChange>> {
val old = rowsOf(before).groupByTo(LinkedHashMap()) { it.lesson.key }
val changed = mutableListOf<LessonChange>()
val unmatched = mutableListOf<ChangedLesson>()
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<ChangedLesson>({ it.date.orEmpty() }, { it.day }, { it.lesson.start })
return Triple(unmatched.sortedWith(order), left.sortedWith(order), changed.sortedWith(compareBy(order) { it.after }))
}

View File

@@ -22,6 +22,49 @@ class Repository(context: Context, val api: Api, private val settings: Settings)
private val _exams = MutableStateFlow(readExams())
val exams: StateFlow<ExamsResponse?> = _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<List<ScheduleChanges>> = _changes
private fun readChanges(): List<ScheduleChanges> =
runCatching { json.decodeFromString<List<ScheduleChanges>>(changesCache.readText()) }.getOrDefault(emptyList())
private fun writeChanges(list: List<ScheduleChanges>) {
_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<ExamsResponse>(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.

View File

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

View File

@@ -131,6 +131,8 @@ fun HomeScreen(vm: MainViewModel, nav: AppNav) {
var details by remember { mutableStateOf<Pair<Lesson, LocalDate>?>(null) }
var editing by remember { mutableStateOf<Note?>(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) }

View File

@@ -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<org.xtimms.psu.schedule.data.ExamsResponse?> = app.repository.exams
/** What changed in the open group's document when the university last republished it. */
val changes: StateFlow<org.xtimms.psu.schedule.data.ScheduleChanges?> =
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<List<Note>> = combine(app.notes.notes, app.settings.flow) { list, s ->
val key = s.current?.let(::groupKey)

View File

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

View File

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

View File

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

View File

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

View File

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

151
ios/Shared/Changes.swift Normal file
View File

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