import Foundation import Observation import WidgetKit /// The last request's outcome, shown as a banner with "Повторить" when it failed. struct Status: Equatable { var loading = false var message: String? var offline = false } /** * The app's state, as the Android MainViewModel and Repository together: settings, the open * group's timetable and exams, tasks and attendance. The last answers stay on disk, so the * schedule, widgets and reminders work without a network. */ @MainActor @Observable final class AppModel { let api = API.shared private(set) var settings: UserSettings private(set) var timetable: Timetable? 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() /** The fallback window for a server that cannot send the whole semester. */ private let span = 35 /** The request that failed last, so a "Повторить" button can run it again. */ private var lastFailed: (() async throws -> Timetable)? init() { settings = Storage.loadSettings() timetable = Storage.loadTimetable() 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)) self?.now = Date() } } } var today: CivilDate { CivilDate.today(now) } var clock: ClockTime { ClockTime.now(now) } var subgroup: String? { settings.subgroup } var hidden: Set { settings.hidden } var groupKey: String? { settings.current?.key } /** Tasks and notes of the open group. */ var notes: [Note] { allNotes.filter { $0.group == groupKey } } /** Attendance marks of the open group. */ var marks: [Mark] { allMarks.filter { $0.group == groupKey } } func slots(_ t: Timetable, on date: CivilDate) -> [Slot] { t.slots(on: date, subgroup: subgroup, hidden: hidden) } /** "Занятия в Полоцке" over the date, for the student's subgroup. */ func places(_ t: Timetable, on date: CivilDate) -> [DayPlace] { t.places(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 [] } return e.exams.filter { (CivilDate($0.date) ?? today) >= today } } // MARK: - Loading func refresh() async { guard let group = settings.current else { status = Status(message: "Группа не выбрана.") return } await perform { [api, span] in try await api.timetable(doc: group.docId, group: group.group, from: CivilDate.today().monday, days: span) } } /** true when the timetable loaded; on failure the status carries the message. */ @discardableResult func open(_ result: GroupResult) async -> Bool { await open(SavedGroup(docId: result.fileId, group: result.group, faculty: result.faculty, course: result.course)) } @discardableResult func open(_ group: SavedGroup) async -> Bool { change { s in s.current = group s.recent = Array(([group] + s.recent.filter { $0.key != group.key }).prefix(8)) } return await perform { [api, span] in try await api.timetable(doc: group.docId, group: group.group, from: CivilDate.today().monday, days: span) } } /** Runs the request that failed last once more (the banners' "Повторить"). */ func retry() async { guard let block = lastFailed else { return } await perform(block) } func dismissMessage() { status.message = nil } @discardableResult private func perform(_ block: @escaping () async throws -> Timetable) async -> Bool { 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 status = Status() afterChange() // Exams come from the whole catalog index; their failure never fails the timetable. let faculty = fresh.document.faculty.isEmpty ? (settings.current?.faculty ?? "") : fresh.document.faculty if let e = try? await api.exams(group: fresh.group, faculty: faculty) { exams = e Storage.write(e, "exams.json") } return true } catch { let e = error as? APIError ?? APIError(message: error.localizedDescription) lastFailed = block // Offline with a timetable on the device still leaves something to look at. status = Status( loading: false, message: e.offline && timetable != nil ? e.message + " Показана сохранённая копия." : e.message, offline: e.offline ) return false } } // MARK: - Settings private func change(_ edit: (inout UserSettings) -> Void) { edit(&settings) Storage.saveSettings(settings) } func setSubgroup(_ label: String?) { guard let key = groupKey else { return } change { $0.subgroups[key] = label } afterChange() } func setTheme(_ mode: ThemeMode) { change { $0.theme = mode } } func setDisplay(_ d: Display) { change { $0.display = d } } func setPlans(_ p: PlansPrefs) { change { $0.plans = p } } /** Hides or shows again a recurring lesson of the open group. */ func setHidden(_ key: String, _ hidden: Bool) { guard let group = groupKey else { return } change { s in var list = s.hiddenByGroup[group] ?? [] list.removeAll { $0 == key } if hidden { list.append(key) } s.hiddenByGroup[group] = list } afterChange() } func setReminders(_ on: Bool) { change { $0.reminders = on } afterChange() } func setChangeAlerts(_ on: Bool) { change { $0.changeAlerts = on } } func setReminderMinutes(_ minutes: Int) { change { $0.reminderMinutes = minutes } afterChange() } // MARK: - Notes and attendance func save(_ note: Note) { if let i = allNotes.firstIndex(where: { $0.id == note.id }) { allNotes[i] = note } else { allNotes.append(note) } Storage.write(allNotes, "notes.json") WidgetCenter.shared.reloadAllTimelines() } func delete(_ note: Note) { allNotes.removeAll { $0.id == note.id } Storage.write(allNotes, "notes.json") } func toggle(_ note: Note) { guard let i = allNotes.firstIndex(where: { $0.id == note.id }) else { return } allNotes[i].done.toggle() Storage.write(allNotes, "notes.json") } /** Marks a lesson as attended (true), missed (false) or clears the mark (nil). */ func mark(_ lesson: Lesson, on date: CivilDate, present: Bool?) { guard let group = groupKey else { return } let mark = Mark(group: group, subject: lesson.subject, date: date.iso, start: lesson.start, present: present ?? false) allMarks.removeAll { $0.id == mark.id } if present != nil { allMarks.append(mark) } Storage.write(allMarks, "attendance.json") } /** Reminders and widgets follow every change of the timetable or the choices. */ private func afterChange() { Reminders.reschedule(timetable: timetable, settings: settings) WidgetCenter.shared.reloadAllTimelines() } } // MARK: - Lookups shared by the screens extension Array where Element == Note { func forLesson(_ subject: String, _ date: CivilDate, _ start: String) -> [Note] { let key = subjectKey(subject) return filter { $0.date == date.iso && $0.start == start && subjectKey($0.subject) == key } } /** Unfinished tasks and notes: what a card should hint at. */ var open: [Note] { filter { !$0.task || !$0.done } } } extension Array where Element == Mark { func forLesson(_ subject: String, _ date: CivilDate, _ start: String) -> Mark? { let key = subjectKey(subject) return first { $0.date == date.iso && $0.start == start && subjectKey($0.subject) == key } } /** Present and marked lessons of a subject. */ func stats(for subject: String) -> (present: Int, total: Int) { let own = filter { subjectKey($0.subject) == subjectKey(subject) } return (own.filter(\.present).count, own.count) } } extension Note { func overdue(today: CivilDate) -> Bool { task && !done && (CivilDate(date) ?? today) < today } /** "к сегодня, 11:45", "к завтра, 10:05", "к пн, 19 октября". */ func dueLabel(today: CivilDate) -> String { let d = CivilDate(date) ?? today let at = start.isEmpty ? "" : ", \(start)" let day: String if d == today { day = "сегодня" + at } else if d == today.adding(days: 1) { day = "завтра" + at } else { day = d.shortWeekday.lowercased() + ", " + d.longLabel } return task ? "к " + day : day } } extension AppModel { /** The next lesson of the same subject after this one, within the loaded weeks. */ func nextOccurrence(of subject: String, after date: CivilDate, start: String) -> (CivilDate, Lesson)? { guard let t = timetable else { return nil } let key = subjectKey(subject) for d in t.dates where d >= date { let found = t.lessons(on: d, subgroup: subgroup, hidden: hidden) .filter { subjectKey($0.subject) == key && (d > date || $0.start > start) } .sorted { $0.start < $1.start } if let first = found.first { return (d, first) } } return nil } /** Attendance makes sense once a lesson has begun. */ func started(_ date: CivilDate, _ start: String) -> Bool { date < today || (date == today && start <= clock.description) } }