Onyx holt sich nichts, sondern liest den Schnappschuss, den Calendarr in den gemeinsamen Ordner schreibt — kein Netzzugriff, kein zweiter Zwischenspeicher. Gemeldet wird das über eine Darwin-Nachricht, und darauf hört Onyx. Die Nachricht ist aber flüchtig: schreibt Calendarr, während Onyx nicht läuft oder gerade startet, kommt sie nie an. Der Schnappschuss steht dann so lange still, bis Calendarr das nächste Mal schreibt — und die Terminliste ist aus unerfindlichen Gründen von gestern. Also zusätzlich alle zwei Minuten nachsehen, solange Calendarr überhaupt gefragt wird. Das kostet das Einlesen einer Datei von vierzehn Kilobyte. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
440 lines
16 KiB
Swift
440 lines
16 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
|
|
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(),
|
|
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)
|
|
self.hiddenCalendarIDs = Set(defaults.stringArray(forKey: Self.hiddenKey) ?? [])
|
|
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) { @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.
|
|
calendarrTimer?.invalidate()
|
|
calendarrTimer = wanted
|
|
? Timer.scheduledTimer(withTimeInterval: 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.
|
|
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 {
|
|
VStack(alignment: .leading, spacing: 5) {
|
|
ForEach(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)
|
|
}
|
|
}
|