import Foundation enum ThemeMode: String, Codable, CaseIterable { case system, light, dark } enum PlansLayout: String, Codable, CaseIterable { case agenda, list, board } /** How the Plans screen lays out tasks, as in School Planner's plan settings. */ struct PlansPrefs: Codable, Hashable { var layout: PlansLayout = .agenda /** The day's lessons above its plans; off by default, so plans come first. */ var lessons: Bool = false /** Ticked-off tasks stay in the list. */ var done: Bool = true } /** What lesson cards, the week grid and the overview show; everything is on by default. */ struct Display: Codable, Hashable { var colors = true var teachers = true var rooms = true var pairs = true var tags = true var notes = true } /** Everything the student chose; stays on the device, shared with the widgets. */ struct UserSettings: Codable, Hashable { var current: SavedGroup? /** The chosen subgroup per group ("docId|group"). */ var subgroups: [String: String] = [:] var theme: ThemeMode = .system var reminders = false var reminderMinutes = 10 /** A notification when the background refresh finds changes in the group's document. */ var changeAlerts = true var recent: [SavedGroup] = [] var display = Display() /** Hidden lessons per group, as Lesson.hideKey values. */ var hiddenByGroup: [String: [String]] = [:] var plans = PlansPrefs() var subgroup: String? { current.flatMap { subgroups[$0.key] } } var hidden: Set { Set(current.flatMap { hiddenByGroup[$0.key] } ?? []) } } // Every field may be missing in an older save. extension UserSettings { init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) current = try c.decodeIfPresent(SavedGroup.self, forKey: .current) subgroups = try c.decodeIfPresent([String: String].self, forKey: .subgroups) ?? [:] theme = try c.decodeIfPresent(ThemeMode.self, forKey: .theme) ?? .system reminders = try c.decodeIfPresent(Bool.self, forKey: .reminders) ?? false reminderMinutes = try c.decodeIfPresent(Int.self, forKey: .reminderMinutes) ?? 10 changeAlerts = try c.decodeIfPresent(Bool.self, forKey: .changeAlerts) ?? true recent = try c.decodeIfPresent([SavedGroup].self, forKey: .recent) ?? [] display = try c.decodeIfPresent(Display.self, forKey: .display) ?? Display() hiddenByGroup = try c.decodeIfPresent([String: [String]].self, forKey: .hiddenByGroup) ?? [:] plans = try c.decodeIfPresent(PlansPrefs.self, forKey: .plans) ?? PlansPrefs() } } /** * Files and settings in the app group container, so the widgets read the same timetable, * notes and choices as the app. Everything stays on the device. */ enum Storage { static let group = "group.org.xtimms.psu.schedule" static var directory: URL { if let url = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: group) { return url } // Without the app group (an unsigned build) the app still works on its own. let url = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) return url } static var defaults: UserDefaults { UserDefaults(suiteName: group) ?? .standard } static let encoder = JSONEncoder() static let decoder = JSONDecoder() static func read(_ type: T.Type, _ name: String) -> T? { guard let data = try? Data(contentsOf: directory.appendingPathComponent(name)) else { return nil } return try? decoder.decode(type, from: data) } static func write(_ value: T, _ name: String) { guard let data = try? encoder.encode(value) else { return } try? data.write(to: directory.appendingPathComponent(name), options: .atomic) } static func remove(_ name: String) { try? FileManager.default.removeItem(at: directory.appendingPathComponent(name)) } static func loadSettings() -> UserSettings { guard let data = defaults.data(forKey: "settings"), let s = try? decoder.decode(UserSettings.self, from: data) else { return UserSettings() } return s } static func saveSettings(_ s: UserSettings) { if let data = try? encoder.encode(s) { defaults.set(data, forKey: "settings") } } static func loadTimetable() -> Timetable? { read(Timetable.self, "timetable.json") } }