Files
onyx/Packages/OnyxKit/Sources/CalendarProvider/CalendarWidget.swift
T
ScarriffleandClaude Opus 5 edb327cabe Kacheln und Fenster sprechen dieselbe Sprache
Alle Kacheln, Popovers und Fenster auf die Designschicht umgestellt: rund
hundert gesetzte Schriftgrößen und 159 gesetzte Abstände und Radien sind jetzt
Token. Übrig bleibt `spacing: 0` — das ist eine Aussage, keine geratene Zahl.

Die Einstellungen bekommen eine Seitenleiste statt sieben Reitern, deren
deutsche Beschriftungen zusammen 880 Punkte brauchten. Panel-Token stehen nicht
mehr in Fenstern: dort gilt Systemsemantik, damit der Hellmodus nicht schwarz
auf schwarz endet.

Neu ist ein Schaufenster, das alle Bauteile nebeneinander zeigt — ohne das
lässt sich eine Änderung an einem Token nur prüfen, indem man die App bedient,
bis jede Kachel einmal zu sehen war.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 21:05:51 +02:00

480 lines
19 KiB
Swift

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) }
}
/// Welche Kalender Daten liefern. `nil` heißt: alle.
///
/// Bewusst eine Liste der **abgewählten**, nicht der gewählten: kommt ein
/// neuer Kalender dazu, ist er dann automatisch dabei. Andersherum müsste
/// man ihn suchen und anhaken, ohne zu wissen, dass es ihn gibt.
public var hiddenCalendarIDs: Set<String> {
didSet {
guard hiddenCalendarIDs != oldValue else { return }
defaults.set(Array(hiddenCalendarIDs), forKey: Self.hiddenKey)
refresh()
}
}
/// Die Kalender, die zur Auswahl stehen.
public private(set) var availableCalendars: [OnyxCalendar] = []
/// Der Zustand der Calendarr-Quelle **allein**, für die Einstellungen.
///
/// Getrennt von `state`, weil das bei „beide" auch Apple-Termine enthält —
/// eine Zahl daraus sagt über Calendarr nichts.
///
/// Und getrennt geführt statt in den Einstellungen nachgeladen: dort stand
/// bisher ein zweiter, eigener Aufruf von `CalendarrSource`, also immer der
/// **Schnappschuss** — auch wenn längst der Server gefragt wurde. Der
/// Zeitstempel daneben war deshalb das Alter einer Datei, die niemand mehr
/// liest, und sah aus wie ein Kalender, der seit Wochen stillsteht.
public private(set) var calendarrState: CalendarSourceState?
private static let kindKey = "calendar.source"
private static let hiddenKey = "calendar.hiddenCalendars"
private static let gridKey = "calendar.showsMonthGrid"
private let appleSource: CalendarSource
private let calendarrSource: CalendarSource
/// Der Weg über den Server. Wird benutzt, sobald Zugangsdaten hinterlegt
/// sind — dann hängt die Aktualität nicht mehr daran, ob jemand die
/// Calendarr-App geöffnet hat.
private let calendarrLive: CalendarrLiveSource
private let defaults: UserDefaults
private var calendar: Calendar
private var refreshTask: Task<Void, Never>?
/// `nil` = nicht auf Calendarr-Änderungen angemeldet.
private var calendarrObserver: DarwinObserver?
/// Das Sicherheitsnetz unter der Benachrichtigung.
private var calendarrTimer: Timer?
public init(appleSource: CalendarSource = EventKitSource(),
calendarrSource: CalendarSource = CalendarrSource(),
calendarrLive: CalendarrLiveSource = CalendarrLiveSource(),
defaults: UserDefaults = .standard,
calendar: Calendar = .autoupdatingCurrent) {
self.appleSource = appleSource
self.calendarrSource = calendarrSource
self.calendarrLive = calendarrLive
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)
self.hiddenCalendarIDs = Set(defaults.stringArray(forKey: Self.hiddenKey) ?? [])
observeCalendarrIfNeeded()
}
/// Die gerade befragten Quellen.
private var activeSources: [CalendarSource] {
switch kind {
case .appleCalendar: [appleSource]
case .calendarr: [calendarrChoice]
case .both: [appleSource, calendarrChoice]
}
}
/// Server oder Schnappschuss.
///
/// Sind Zugangsdaten hinterlegt, gilt der Server — er ist die genauere
/// Auskunft. Der Schnappschuss bleibt das Netz darunter: er braucht kein
/// Netzwerk und hält, was Calendarr zuletzt geschrieben hat.
private var calendarrChoice: CalendarSource {
calendarrLive.isAuthorized ? calendarrLive : calendarrSource
}
/// Ob gerade direkt vom Server gelesen wird.
public var usesCalendarrServer: Bool { calendarrLive.isAuthorized }
/// 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) { @Sendable [weak self] in
Task { @MainActor in self?.refresh() }
}
: nil
// Dazu ein Blick alle zwei Minuten.
//
// Die Benachrichtigung ist der schnelle Weg, aber sie ist flüchtig:
// schreibt Calendarr, während Onyx nicht läuft oder gerade startet,
// kommt sie nie an — und dann steht der Schnappschuss so lange still,
// bis Calendarr das nächste Mal schreibt. Das Nachsehen kostet das
// Einlesen einer Datei von wenigen Kilobyte; das ist billiger als eine
// Terminliste, die aus unerfindlichen Gründen von gestern ist.
// Am Server seltener als an der Datei: ein Dateizugriff kostet nichts,
// eine Anfrage über das Netz schon.
calendarrTimer?.invalidate()
calendarrTimer = wanted
? Timer.scheduledTimer(withTimeInterval: usesCalendarrServer ? 300 : 120,
repeats: true) { _ in
MainActor.assumeIsolated { [weak self] 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.
/// Nach dem Verbinden oder Trennen: Quelle und Takt neu bestimmen.
public func calendarrAccountChanged() {
calendarrObserver = nil
calendarrTimer?.invalidate()
calendarrTimer = nil
observeCalendarrIfNeeded()
refresh()
}
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] = []
let calendarr = kind == .appleCalendar ? nil : calendarrChoice as AnyObject
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.
let loaded = source.isAuthorized ? await source.load(interval) : .denied
if let calendarr, source as AnyObject === calendarr { calendarrState = loaded }
states.append(loaded)
}
guard !Task.isCancelled else { return }
state = CalendarSourceKind.merge(states).filteringHidden(hiddenCalendarIDs)
// Die Liste der wählbaren Kalender nebenbei mitführen — sie ändert
// sich, wenn jemand in Kalender einen anlegt oder die Quelle
// wechselt.
var found: [OnyxCalendar] = []
for source in activeSources where source.isAuthorized {
found.append(contentsOf: await source.calendars())
}
availableCalendars = found
}
}
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:
OnyxNotice(symbol: "lock",
text: String(localized: "widget.calendar.denied", bundle: .module))
case .neverWritten:
OnyxNotice(symbol: "clock.arrow.circlepath",
text: String(localized: "widget.calendar.neverWritten", bundle: .module))
case .loggedOut:
OnyxNotice(symbol: "person.crop.circle.badge.xmark",
text: String(localized: "widget.calendar.loggedOut", bundle: .module))
case .incompatible:
OnyxNotice(symbol: "exclamationmark.triangle",
text: String(localized: "widget.calendar.incompatible", bundle: .module))
case .unreadable:
OnyxNotice(symbol: "questionmark.folder",
text: String(localized: "widget.calendar.unreadable", bundle: .module))
}
}
.task { model.refresh() }
.onDisappear { model.stop() }
}
}
// 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: Onyx.Space.md) {
MiniMonth(window: window)
OnyxHairline()
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: Onyx.Space.xs) {
HStack(spacing: 0) {
ForEach(weekdaySymbols, id: \.self) { symbol in
Text(symbol)
.font(Onyx.Font.micro)
.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[..<shift])
}
}
private struct DayCell: View {
let day: MonthDay
let status: DayStatus
let isToday: Bool
var body: some View {
ZStack {
if isToday {
Circle().fill(Onyx.Color.accent.opacity(0.9)).frame(width: 15, height: 15)
}
Text("\(Calendar.autoupdatingCurrent.component(.day, from: day.date))")
.font(Onyx.Font.micro.weight(isToday ? .semibold : .regular))
.foregroundStyle(textColor)
}
.frame(maxWidth: .infinity)
.frame(height: 15)
.overlay(alignment: .bottom) {
// Ein Punkt heißt: hier ist etwas. Ein fehlender Punkt heißt nur
// dann „nichts", wenn der Tag überhaupt abgedeckt ist — deshalb
// wird `unknown` ausgegraut statt leer gelassen.
if status == .busy {
Circle()
.fill(isToday ? Color.white : Onyx.Color.accent)
.frame(width: 2.5, height: 2.5)
.offset(y: 2)
}
}
.opacity(status == .unknown ? 0.25 : 1)
.help(status == .unknown
? Text("widget.calendar.day.unknown", bundle: .module)
: Text(""))
}
private var textColor: Color {
if isToday { return .white }
return day.isInMonth ? Onyx.Color.textPrimary : Onyx.Color.textTertiary
}
}
// MARK: - Terminliste
private struct AgendaList: View {
let events: [OnyxEvent]
let coverage: DateInterval?
var body: some View {
if events.isEmpty {
Text("widget.calendar.noUpcoming", bundle: .module)
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textTertiary)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
} else {
// Nach Tagen getrennt: „8:00" allein sagt nicht, an welchem.
let days = AgendaGrouping.group(events)
VStack(alignment: .leading, spacing: Onyx.Space.xs) {
ForEach(Array(days.enumerated()), id: \.element.id) { index, day in
Text(AgendaGrouping.label(for: day.date))
.font(Onyx.Font.micro.weight(.semibold))
.foregroundStyle(Onyx.Color.textSecondary)
.textCase(.uppercase)
// Luft nur zwischen den Tagen, nicht über dem ersten.
.padding(.top, index == 0 ? 0 : 3)
ForEach(day.events) { event in
EventRow(event: event)
}
}
Spacer(minLength: 0)
}
}
}
}
private struct EventRow: View {
let event: OnyxEvent
var body: some View {
HStack(spacing: Onyx.Space.sm) {
RoundedRectangle(cornerRadius: 1.5, style: .continuous)
.fill(Color(hex: event.colorHex) ?? Onyx.Color.accent)
.frame(width: 3)
VStack(alignment: .leading, spacing: Onyx.Space.xxs) {
Text(event.title)
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textPrimary)
.lineLimit(1)
Text(timeText)
.font(Onyx.Font.micro)
.monospacedDigit()
.foregroundStyle(Onyx.Color.textTertiary)
.lineLimit(1)
}
Spacer(minLength: 0)
}
.frame(height: 24)
}
private var timeText: String {
if event.isAllDay {
return String(localized: "widget.calendar.allDay", bundle: .module)
}
return event.start.formatted(date: .omitted, time: .shortened)
}
}
extension Color {
/// `#RRGGBB` aus einer fremden Quelle. Ungültige Werte ergeben `nil`, damit
/// die Aufrufstelle auf die Onyx-Akzentfarbe zurückfallen kann.
init?(hex: String?) {
guard let hex else { return nil }
var value = hex.trimmingCharacters(in: .whitespaces)
if value.hasPrefix("#") { value.removeFirst() }
guard value.count == 6, let number = UInt32(value, radix: 16) else { return nil }
self.init(red: Double((number >> 16) & 0xFF) / 255,
green: Double((number >> 8) & 0xFF) / 255,
blue: Double(number & 0xFF) / 255)
}
}