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

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