import SwiftUI import OnyxDesign import OnyxWidgetKit /// Hält die Termine für das Widget und lädt sie nach. @MainActor @Observable public final class CalendarModel { public private(set) var state: CalendarSourceState = .ready(.empty) public private(set) var isLoading = false private let source: CalendarSource private var calendar: Calendar private var refreshTask: Task? public init(source: CalendarSource, calendar: Calendar = .autoupdatingCurrent) { self.source = source self.calendar = calendar } public var window: EventWindow? { if case .ready(let window) = state { return window } return nil } /// Termine für den sichtbaren Monat plus Puffer nach beiden Seiten, damit /// die Randtage der Nachbarmonate im Raster nicht fälschlich leer wirken. public func refresh(around date: Date = Date()) { refreshTask?.cancel() refreshTask = Task { [weak self] in guard let self else { return } isLoading = true defer { isLoading = false } guard await source.requestAccess() else { state = .denied return } let start = calendar.date(byAdding: .day, value: -14, to: calendar.startOfDay(for: date))! let end = calendar.date(byAdding: .day, value: 60, to: start)! guard !Task.isCancelled else { return } state = await source.load(DateInterval(start: start, end: end)) } } public func stop() { refreshTask?.cancel() refreshTask = nil } public var upcoming: [OnyxEvent] { guard let window else { return [] } let now = Date() return window.events .filter { $0.end >= now } .prefix(6) .map { $0 } } } // MARK: - Widget public struct CalendarWidget: OnyxWidget { public let id = "calendar" public var displayName: String { String(localized: "widget.calendar.name", bundle: .module) } public let symbolName = "calendar" public let supportedSizes: [WidgetSize] = [.medium, .large] private let model: CalendarModel public init(model: CalendarModel) { self.model = model } public func makeView(size: WidgetSize) -> AnyView { AnyView(CalendarWidgetView(model: model, size: size)) } } private struct CalendarWidgetView: View { let model: CalendarModel let size: WidgetSize var body: some View { Group { switch model.state { case .ready(let window): if size == .large { LargeCalendar(window: window, upcoming: model.upcoming) } else { AgendaList(events: model.upcoming, coverage: window.coverage) } case .denied: CalendarNotice(symbol: "lock", text: "widget.calendar.denied") case .neverWritten: CalendarNotice(symbol: "clock.arrow.circlepath", text: "widget.calendar.neverWritten") case .loggedOut: CalendarNotice(symbol: "person.crop.circle.badge.xmark", text: "widget.calendar.loggedOut") case .incompatible: CalendarNotice(symbol: "exclamationmark.triangle", text: "widget.calendar.incompatible") case .unreadable: CalendarNotice(symbol: "questionmark.folder", text: "widget.calendar.unreadable") } } .task { model.refresh() } .onDisappear { model.stop() } } } /// Kein Platzhalter, sondern eine Antwort: jeder Zustand sagt, was zu tun ist. private struct CalendarNotice: View { let symbol: String let text: LocalizedStringKey var body: some View { VStack(spacing: 6) { Image(systemName: symbol) .font(.system(size: 16)) .foregroundStyle(Onyx.Color.textTertiary) Text(text, bundle: .module) .font(Onyx.Font.caption) .foregroundStyle(Onyx.Color.textSecondary) .multilineTextAlignment(.center) } .frame(maxWidth: .infinity, maxHeight: .infinity) } } // MARK: - Mini-Monat private struct LargeCalendar: View { let window: EventWindow let upcoming: [OnyxEvent] private var calendar: Calendar { .autoupdatingCurrent } var body: some View { VStack(alignment: .leading, spacing: 8) { MiniMonth(window: window) Divider().overlay(Onyx.Color.hairline) AgendaList(events: Array(upcoming.prefix(3)), coverage: window.coverage) } } } private struct MiniMonth: View { let window: EventWindow private var calendar: Calendar { .autoupdatingCurrent } var body: some View { let grid = MonthGrid(containing: Date(), calendar: calendar) let busy = window.events.busyDays(calendar: calendar) let today = calendar.startOfDay(for: Date()) VStack(spacing: 3) { HStack(spacing: 0) { ForEach(weekdaySymbols, id: \.self) { symbol in Text(symbol) .font(.system(size: 8, weight: .medium)) .foregroundStyle(Onyx.Color.textTertiary) .frame(maxWidth: .infinity) } } ForEach(0..<(grid.days.count / 7), id: \.self) { week in HStack(spacing: 0) { ForEach(grid.days[(week * 7)..<(week * 7 + 7)]) { day in DayCell(day: day, status: grid.status(for: day.date, coverage: window.coverage, busyDays: busy), isToday: day.date == today) } } } } } /// Wochentagskürzel ab dem eingestellten Wochenanfang. private var weekdaySymbols: [String] { let symbols = calendar.veryShortStandaloneWeekdaySymbols let shift = calendar.firstWeekday - 1 return Array(symbols[shift...] + symbols[..> 16) & 0xFF) / 255, green: Double((number >> 8) & 0xFF) / 255, blue: Double(number & 0xFF) / 255) } }