Files
psu-schedule/ios/PSUSchedule/AppModel.swift

296 lines
11 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<String> { 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)
}
/** 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)
}
}