Files
onyx/Packages/OnyxKit/Sources/CalendarProvider/CalendarWidget.swift
Scarriffle 9975694d6a Terminliste: an welchem Tag denn?
Zweimal „Nina arbeiten, 8:00" untereinander sind zwei verschiedene Tage, und
nichts verriet welche. Die Uhrzeit beantwortet nur die halbe Frage.

Jetzt steht über jedem Tag eine Überschrift: „Heute", „Morgen", danach
Wochentag und Datum. Die Wörter für die nächsten beiden Tage, weil sie kürzer
sind als jedes Datum und sofort beantworten, was man wissen will. Der
Wochentag allein wäre in drei Wochen mehrdeutig, deshalb steht das Datum
dabei.

Ganztägige Termine stehen jetzt über den Uhrzeiten desselben Tages. Sie
beginnen um Mitternacht und standen deshalb zwischen den frühen Terminen —
dabei gelten sie für den ganzen Tag.

Die Entscheidung „heute/morgen/später" liegt getrennt von ihrer Beschriftung,
damit sie ohne Sprachkatalog prüfbar bleibt: im Testlauf steht sonst der rohe
Schlüssel statt des Wortes, und der Test prüft die Übersetzung statt der
Logik.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 13:52:16 +02:00

480 lines
18 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] = []
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] = []
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).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:
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[..<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(.system(size: 9, 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: 4) {
ForEach(Array(days.enumerated()), id: \.element.id) { index, day in
Text(AgendaGrouping.label(for: day.date))
.font(.system(size: 9, 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: 6) {
RoundedRectangle(cornerRadius: 1.5, style: .continuous)
.fill(Color(hex: event.colorHex) ?? Onyx.Color.accent)
.frame(width: 3)
VStack(alignment: .leading, spacing: 1) {
Text(event.title)
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textPrimary)
.lineLimit(1)
Text(timeText)
.font(.system(size: 9))
.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)
}
}