import SwiftUI import WidgetKit @main struct MarianumWidgetBundle: WidgetBundle { var body: some Widget { TimetableDayWidget() TimetableWeekWidget() } } // MARK: - Day widget struct TimetableDayWidget: Widget { let kind: String = "TimetableDayWidget" var body: some WidgetConfiguration { StaticConfiguration(kind: kind, provider: TimetableDayProvider()) { entry in TimetableDayView(entry: entry).widgetSurface(entry: entry) } .configurationDisplayName("Marianum · Heute") .description("Stundenplan und Vertretungen für den anstehenden Schultag.") // Only .systemLarge: iOS gives us no tall-portrait family, so the // 162×162 .systemSmall can't fit a readable 11-period day grid, // and .systemMedium is short-wide which crunches the labels. // Single useful size beats three crippled ones. .supportedFamilies([.systemLarge]) } } struct TimetableDayProvider: TimelineProvider { func placeholder(in context: Context) -> TimetableEntry { TimetableEntry.placeholder() } func getSnapshot(in context: Context, completion: @escaping (TimetableEntry) -> Void) { completion(TimetableEntry.current(variant: .day)) } func getTimeline( in context: Context, completion: @escaping (Timeline) -> Void ) { let now = Date() // 30 min mirrors the Dart workmanager cadence. iOS treats this as // advisory; the boundary entries below keep the rendered day correct // even when no reload is granted, and the "Stand:" label tells the // user when the underlying data is stale. let next = Calendar.current.date(byAdding: .minute, value: 30, to: now) ?? now completion(Timeline(entries: TimetableEntry.dayEntries(now: now), policy: .after(next))) } } // MARK: - Week widget struct TimetableWeekWidget: Widget { let kind: String = "TimetableWeekWidget" var body: some WidgetConfiguration { StaticConfiguration(kind: kind, provider: TimetableWeekProvider()) { entry in TimetableWeekView(entry: entry).widgetSurface(entry: entry) } .configurationDisplayName("Marianum · Woche") .description("Stundenplan und Vertretungen für die ganze Schulwoche.") // .systemMedium is dropped — 5 day columns plus a time-label column // in a 4×2 cell-strip yields per-column widths well below the // subject-only threshold and the labels in the time sidebar overlap. .supportedFamilies([.systemLarge, .systemExtraLarge]) } } struct TimetableWeekProvider: TimelineProvider { func placeholder(in context: Context) -> TimetableEntry { TimetableEntry.placeholder() } func getSnapshot(in context: Context, completion: @escaping (TimetableEntry) -> Void) { completion(TimetableEntry.current(variant: .week)) } func getTimeline( in context: Context, completion: @escaping (Timeline) -> Void ) { let now = Date() let next = Calendar.current.date(byAdding: .minute, value: 30, to: now) ?? now completion(Timeline(entries: TimetableEntry.weekEntries(now: now), policy: .after(next))) } } // MARK: - Entry enum TimetableVariant { case day, week } struct TimetableEntry: TimelineEntry { let date: Date let variant: TimetableVariant let data: WidgetTimetableData? let isLoggedIn: Bool let themeMode: String static func placeholder() -> TimetableEntry { TimetableEntry( date: Date(), variant: .day, data: nil, isLoggedIn: true, themeMode: "system" ) } static func current(variant: TimetableVariant) -> TimetableEntry { let isLoggedIn = WidgetDataLoader.isLoggedIn() let data = isLoggedIn ? (variant == .day ? WidgetDataLoader.loadDay() : WidgetDataLoader.loadWeek()) : nil return TimetableEntry( date: Date(), variant: variant, data: data, isLoggedIn: isLoggedIn, themeMode: WidgetDataLoader.themeMode() ) } /// Day timeline derived from the 14-day week payload, so the widget shows /// the right day even when iOS grants no reload for days. static func dayEntries(now: Date) -> [TimetableEntry] { entries(now: now, variant: .day) { week, date in TimetableAnchor.slice(week: week, forDay: TimetableAnchor.resolveDayAnchor(date)) } } /// Week timeline: re-anchoring performs the Friday-evening/weekend jump /// into next week from cached data, and the midnight entries keep the /// "Stand:" freshness label honest. static func weekEntries(now: Date) -> [TimetableEntry] { entries(now: now, variant: .week) { week, date in TimetableAnchor.retarget(week: week, anchorDate: TimetableAnchor.resolveWeekAnchor(date)) } } /// Shared timeline skeleton: one entry now plus one per anchor boundary /// (midnight rollover, 17:00 cutoff). Boundaries that cannot change the /// render — same anchor and same calendar day for the header labels — /// are dropped. private static func entries( now: Date, variant: TimetableVariant, transform: (WidgetTimetableData, Date) -> WidgetTimetableData ) -> [TimetableEntry] { guard WidgetDataLoader.isLoggedIn(), let week = WidgetDataLoader.loadWeek() else { // Logged out, or no v2 week snapshot yet (fresh app update): // fall back to the legacy single-entry payload. return [TimetableEntry.current(variant: variant)] } let theme = WidgetDataLoader.themeMode() let cal = Calendar.current var result: [TimetableEntry] = [] for date in [now] + TimetableAnchor.boundaryDates(from: now) { let data = transform(week, date) if let previous = result.last, let previousData = previous.data, cal.isDate(previousData.anchorDate, inSameDayAs: data.anchorDate), cal.isDate(previous.date, inSameDayAs: date) { continue } result.append(TimetableEntry( date: date, variant: variant, data: data, isLoggedIn: true, themeMode: theme )) } return result } } extension View { /// Applies the user's chosen light/dark override on top of the system /// scheme so the widget honours the in-app theme setting. @ViewBuilder func widgetThemeOverride(_ mode: String) -> some View { switch mode { case "light": self.environment(\.colorScheme, .light) case "dark": self.environment(\.colorScheme, .dark) default: self } } /// Wraps the widget view in the Marianum palette + container background /// so all subviews can read `\.widgetPalette` and so the widget renders /// in our warm off-white / dark-clay instead of system grey. @ViewBuilder func widgetSurface(entry: TimetableEntry) -> some View { WidgetSurface(entry: entry) { self } } } private struct WidgetSurface: View { let entry: TimetableEntry @ViewBuilder let content: () -> Content @Environment(\.colorScheme) private var colorScheme var body: some View { let palette = WidgetPalette.resolve( themeMode: entry.themeMode, colorScheme: colorScheme ) return AnyView( background(content: content(), palette: palette) .environment(\.widgetPalette, palette) .widgetThemeOverride(entry.themeMode) ) } @ViewBuilder private func background(content: C, palette: WidgetPalette) -> some View { if #available(iOS 17.0, *) { content.containerBackground(palette.background, for: .widget) } else { content.background(palette.background) } } }