- Introduced `Dates.swift` for handling date and time in the university's timezone. - Created `Models.swift` to define data structures for timetable, lessons, and directory search results. - Implemented `Schedule.swift` for managing lesson slots and timetable functionalities. - Developed `Storage.swift` for user settings and data persistence. - Added `Widgets.swift` to create widgets displaying lessons and upcoming classes.
271 lines
10 KiB
Swift
271 lines
10 KiB
Swift
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]
|
||
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") ?? []
|
||
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)
|
||
}
|
||
|
||
/** 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()
|
||
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 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)
|
||
}
|
||
}
|