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:
@@ -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]) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user