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:
270
ios/PSUSchedule/AppModel.swift
Normal file
270
ios/PSUSchedule/AppModel.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user