226 lines
8.0 KiB
Swift
226 lines
8.0 KiB
Swift
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<TimetableEntry>) -> 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<TimetableEntry>) -> 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<Content: View>: 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<C: View>(content: C, palette: WidgetPalette) -> some View {
|
||
if #available(iOS 17.0, *) {
|
||
content.containerBackground(palette.background, for: .widget)
|
||
} else {
|
||
content.background(palette.background)
|
||
}
|
||
}
|
||
}
|