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:
@@ -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 и скрытие таких занятий.
|
||||
- **Планы:** три макета — повестка дня с лентой недели, список и доска. Можно показывать пары дня и выполненные задания. Кнопка «+» — стеклянная.
|
||||
- **Календарь:** месяц с отметками пар и планов, зелёные недели подсвечены; ниже — план выбранного дня.
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
114
ios/PSUSchedule/Views/ChangesView.swift
Normal file
114
ios/PSUSchedule/Views/ChangesView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
151
ios/Shared/Changes.swift
Normal 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) })
|
||||
}
|
||||
Reference in New Issue
Block a user