import SwiftUI import OSLog import EventKit import OnyxDesign import OnyxWidgetKit private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Calendar") /// 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 /// Welche Quellen gefragt werden. Umschalten lädt sofort neu. public var kind: CalendarSourceKind { didSet { guard kind != oldValue else { return } defaults.set(kind.rawValue, forKey: Self.kindKey) observeCalendarrIfNeeded() refresh() } } /// Monatsraster statt Terminliste. public var showsMonthGrid: Bool { didSet { defaults.set(showsMonthGrid, forKey: Self.gridKey) } } private static let kindKey = "calendar.source" private static let gridKey = "calendar.showsMonthGrid" private let appleSource: CalendarSource private let calendarrSource: CalendarSource private let defaults: UserDefaults private var calendar: Calendar private var refreshTask: Task? /// `nil` = nicht auf Calendarr-Änderungen angemeldet. private var calendarrObserver: DarwinObserver? public init(appleSource: CalendarSource = EventKitSource(), calendarrSource: CalendarSource = CalendarrSource(), defaults: UserDefaults = .standard, calendar: Calendar = .autoupdatingCurrent) { self.appleSource = appleSource self.calendarrSource = calendarrSource self.defaults = defaults self.calendar = calendar self.kind = defaults.string(forKey: Self.kindKey) .flatMap(CalendarSourceKind.init(rawValue:)) ?? .appleCalendar self.showsMonthGrid = defaults.bool(forKey: Self.gridKey) observeCalendarrIfNeeded() } /// Die gerade befragten Quellen. private var activeSources: [CalendarSource] { switch kind { case .appleCalendar: [appleSource] case .calendarr: [calendarrSource] case .both: [appleSource, calendarrSource] } } /// Ob gelesen werden darf. Fragt nicht nach — nur zum Anzeigen. /// /// **Eine** berechtigte Quelle genügt: bei „beide" darf ein gesperrter /// Apple-Kalender nicht dazu führen, dass auch Calendarr stumm bleibt. public var hasAccess: Bool { activeSources.contains(where: \.isAuthorized) } /// Der Berechtigungszustand, der die Oberfläche interessiert — also der des /// Apple-Kalenders, denn nur der kann überhaupt einen Dialog auslösen. public var authorization: CalendarAuthorization { kind == .calendarr ? .notRequired : appleSource.authorization } // MARK: - Calendarr-Benachrichtigung /// Calendarr meldet neue Schnappschüsse per Darwin-Nachricht — deshalb muss /// Onyx die Datei nicht abfragen, sondern erfährt Änderungen. /// /// Nur angemeldet, solange Calendarr überhaupt gefragt wird. Wer nur den /// Apple-Kalender nutzt, soll von Calendarr auch nicht geweckt werden. private func observeCalendarrIfNeeded() { let wanted = kind != .appleCalendar guard wanted != (calendarrObserver != nil) else { return } calendarrObserver = wanted ? DarwinObserver(name: CalendarrSource.changeNotification) { [weak self] in Task { @MainActor in self?.refresh() } } : nil } /// Fragt die Berechtigung an und lädt bei Erfolg gleich. /// /// Wird beim Start gerufen, nicht erst wenn das Widget sichtbar wird. Das /// Panel ist beim Start zu, also erscheint eine View-gebundene Anfrage erst, /// wenn jemand die Notch berührt — und bis dahin steht in den /// Systemeinstellungen kein Onyx, das man freischalten könnte. @discardableResult public func requestAccessNow() async -> Bool { log.notice("Kalenderzugriff: Status vor der Anfrage = \(EKEventStore.authorizationStatus(for: .event).rawValue, privacy: .public)") let granted = await appleSource.requestAccess() log.notice("Kalenderzugriff: erteilt = \(granted, privacy: .public), Status danach = \(EKEventStore.authorizationStatus(for: .event).rawValue, privacy: .public)") // Auch bei Ablehnung neu laden statt hart auf `denied` zu setzen: läuft // Calendarr mit, hat Onyx weiterhin Termine zu zeigen. refresh() return granted } 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 } let start = calendar.date(byAdding: .day, value: -14, to: calendar.startOfDay(for: date))! let end = calendar.date(byAdding: .day, value: 60, to: start)! let interval = DateInterval(start: start, end: end) var states: [CalendarSourceState] = [] for source in activeSources { guard !Task.isCancelled else { return } // Hier wird NICHT nachgefragt. Ein Berechtigungsdialog aus einer // App ohne Fenster heraus wird von TCC verworfen, ohne dass etwas // passiert — die Anfrage muss aus dem Einstellungsfenster kommen. // Ohne Berechtigung ist der ehrliche Zustand `denied` und nicht // eine leere Terminliste, die "nichts mehr heute" behaupten würde. states.append(source.isAuthorized ? await source.load(interval) : .denied) } guard !Task.isCancelled else { return } state = CalendarSourceKind.merge(states) } } 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" private let model: CalendarModel public init(model: CalendarModel) { self.model = model } public func makeView() -> AnyView { AnyView(CalendarWidgetView(model: model)) } } private struct CalendarWidgetView: View { let model: CalendarModel var body: some View { Group { switch model.state { case .ready(let window): // Monatsraster oder Terminliste — das ist keine Frage der // Größe, sondern was man sehen will. Beides zugleich passt in // eine Kachel nicht, ohne dass eines davon unlesbar wird. if model.showsMonthGrid { LargeCalendar(window: window, 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) } }