Add date and time handling, models, schedule management, storage, and widget support

- 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.
This commit is contained in:
2026-10-18 20:26:12 +03:00
parent 6b68cec34a
commit 170a44c377
40 changed files with 58862 additions and 51029 deletions

View File

@@ -0,0 +1,270 @@
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)
}
}