import Foundation /// Mirrors lib/widget_data/widget_data.dart. JSON keys must stay in sync — /// the bridge is one-way: Dart writes, Swift reads. enum WidgetLessonStatus: String, Codable { case regular case ongoing case past case cancelled case irregular case teacherChanged case event } struct WidgetLesson: Codable { let start: Date let end: Date let subjectShort: String let subjectLong: String? let room: String? let teacher: String? let originalTeacher: String? let status: WidgetLessonStatus let customColor: String? let siblingCount: Int? } struct WidgetPeriod: Codable { let name: String let startMinutes: Int let endMinutes: Int let virtualStartMinutes: Int let virtualEndMinutes: Int } struct WidgetTimetableData: Codable { let fetchedAt: Date let anchorDate: Date let lessons: [WidgetLesson] let periods: [WidgetPeriod] let isHoliday: Bool let holidayName: String? } enum WidgetDataKey { static let appGroupId = "group.eu.mhsl.marianum.mobile.client.widget" static let dayData = "widget_data_day_v1" static let weekData = "widget_data_week_v1" static let loggedIn = "widget_data_logged_in_v1" static let themeMode = "widget_setting_theme_mode_v1" } enum WidgetDataLoader { /// Dart's `DateTime.toIso8601String()` on a non-UTC DateTime drops the /// trailing Z and ships local wall-clock time. ISO8601DateFormatter's /// default treats that as UTC and shifts every lesson by the local TZ /// offset — dispatch by suffix instead, mirroring WidgetDataParser.kt. private static func parseDartDate(_ raw: String) -> Date? { let hasTzSuffix = raw.hasSuffix("Z") || raw.range(of: #"[+-]\d{2}:?\d{2}$"#, options: .regularExpression) != nil if hasTzSuffix { let iso = ISO8601DateFormatter() iso.formatOptions = [.withFullDate, .withFullTime, .withFractionalSeconds] if let d = iso.date(from: raw) { return d } iso.formatOptions = [.withFullDate, .withFullTime] return iso.date(from: raw) } for pattern in [ "yyyy-MM-dd'T'HH:mm:ss.SSSSSS", "yyyy-MM-dd'T'HH:mm:ss.SSS", "yyyy-MM-dd'T'HH:mm:ss", ] { let f = DateFormatter() f.dateFormat = pattern f.timeZone = TimeZone.current f.locale = Locale(identifier: "en_US_POSIX") if let d = f.date(from: raw) { return d } } return nil } private static func decoder() -> JSONDecoder { let dec = JSONDecoder() dec.dateDecodingStrategy = .custom { decoder in let container = try decoder.singleValueContainer() let raw = try container.decode(String.self) if let d = parseDartDate(raw) { return d } throw DecodingError.dataCorruptedError( in: container, debugDescription: "Unparseable date: \(raw)" ) } return dec } static func loadDay() -> WidgetTimetableData? { load(key: WidgetDataKey.dayData) } static func loadWeek() -> WidgetTimetableData? { load(key: WidgetDataKey.weekData) } static func isLoggedIn() -> Bool { let defaults = UserDefaults(suiteName: WidgetDataKey.appGroupId) return defaults?.bool(forKey: WidgetDataKey.loggedIn) ?? false } /// "light" / "dark" / "system". The view's `.environment(\.colorScheme)` /// reads this so the App's theme choice wins over the OS-level setting. static func themeMode() -> String { let defaults = UserDefaults(suiteName: WidgetDataKey.appGroupId) return defaults?.string(forKey: WidgetDataKey.themeMode) ?? "system" } private static func load(key: String) -> WidgetTimetableData? { guard let defaults = UserDefaults(suiteName: WidgetDataKey.appGroupId), let raw = defaults.string(forKey: key), let data = raw.data(using: .utf8) else { return nil } do { return try decoder().decode(WidgetTimetableData.self, from: data) } catch { return nil } } }