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

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