Phase 3: Kalender-Widget mit echten EventKit-Daten
Die Unterscheidung zwischen "keine Termine" und "keine Information" steckt von Anfang an in den Grundlagen, nicht als Nachtrag für Calendarr. Ein Kalender ohne Termine und ein Kalender, über den nichts bekannt ist, sehen in einer Liste identisch aus — nämlich leer. Ein leerer März ist überzeugend und schlicht falsch, wenn die Quelle nur 49 Tage abdeckt. EventWindow trägt deshalb eine coverage mit. Bei EventKit ist sie der angefragte Zeitraum, weil direkt abgefragt wird; bei Calendarr wird sie später aus dem Snapshot kommen. Tage außerhalb werden im Mini-Monat ausgegraut und tragen einen erklärenden Hinweis, statt als frei durchzugehen. Die Abdeckung gewinnt auch gegen widersprüchliche Eingaben: liefert eine Quelle einen Termin für einen Tag, den sie nach eigener Angabe nicht abdeckt, bleibt der Tag unbekannt. Sonst behauptet die Anzeige mehr zu wissen, als belegt ist. Als Test festgehalten. CalendarSourceState statt eines Optionals: denied, neverWritten, loggedOut, incompatible und unreadable bekommen jeweils eine eigene Antwort in der Oberfläche. "Nichts anzuzeigen" ist keine. Serientermine bekommen eine aus Kennung und Beginn zusammengesetzte ID — EKEvent.eventIdentifier ist für alle Vorkommen gleich, eine Terminliste mit einer täglichen Serie fiele sonst auf einen Eintrag zusammen. Monatsraster testgetrieben, inklusive Schaltjahr, Wochenstart Montag wie Sonntag und der Regel, dass das Ende der Abdeckung nicht mehr dazugehört. 93 Tests grün.
This commit is contained in:
@@ -16,6 +16,7 @@
|
||||
A529046C33E6B00C0E9508FF /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 508DECB5C05264E6130C373E /* Localizable.xcstrings */; };
|
||||
B6CD48B41866A468AE7A1BC7 /* OnyxMenuBar in Frameworks */ = {isa = PBXBuildFile; productRef = A7FD7A4864E5ED4B95C211AF /* OnyxMenuBar */; };
|
||||
C041F171FB69F4381309D978 /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B83C4E3CB821F903F3977E88 /* AppModel.swift */; };
|
||||
CD501EBAE39807F5D354C5B2 /* CalendarProvider in Frameworks */ = {isa = PBXBuildFile; productRef = A3E3949D664131D593CBEEDC /* CalendarProvider */; };
|
||||
EA4D6312E8A96E177F10797F /* OnyxNotch in Frameworks */ = {isa = PBXBuildFile; productRef = 3A32CAE6CC4D01D660F0016F /* OnyxNotch */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
@@ -41,6 +42,7 @@
|
||||
EA4D6312E8A96E177F10797F /* OnyxNotch in Frameworks */,
|
||||
378A5A417112CCB31EF3F774 /* OnyxWidgetKit in Frameworks */,
|
||||
B6CD48B41866A468AE7A1BC7 /* OnyxMenuBar in Frameworks */,
|
||||
CD501EBAE39807F5D354C5B2 /* CalendarProvider in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -116,6 +118,7 @@
|
||||
3A32CAE6CC4D01D660F0016F /* OnyxNotch */,
|
||||
E66577A40689DF225BCED184 /* OnyxWidgetKit */,
|
||||
A7FD7A4864E5ED4B95C211AF /* OnyxMenuBar */,
|
||||
A3E3949D664131D593CBEEDC /* CalendarProvider */,
|
||||
);
|
||||
productName = Onyx;
|
||||
productReference = 0804288DC4A8146DD9F3FC3E /* Onyx.app */;
|
||||
@@ -393,6 +396,10 @@
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = OnyxDesign;
|
||||
};
|
||||
A3E3949D664131D593CBEEDC /* CalendarProvider */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = CalendarProvider;
|
||||
};
|
||||
A7FD7A4864E5ED4B95C211AF /* OnyxMenuBar */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = OnyxMenuBar;
|
||||
|
||||
@@ -5,6 +5,7 @@ import OnyxDesign
|
||||
import OnyxNotch
|
||||
import OnyxMenuBar
|
||||
import OnyxWidgetKit
|
||||
import CalendarProvider
|
||||
|
||||
@main
|
||||
struct OnyxApp: App {
|
||||
@@ -33,6 +34,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
private var coordinator: NotchCoordinator?
|
||||
private var menuBar: MenuBarController?
|
||||
private var onyxItem: NSStatusItem?
|
||||
private var calendarModel: CalendarModel?
|
||||
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
NSApp.setActivationPolicy(.accessory)
|
||||
@@ -46,7 +48,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
return
|
||||
}
|
||||
|
||||
PlaceholderWidgets.registerAll()
|
||||
registerWidgets()
|
||||
|
||||
let model = AppModel()
|
||||
self.model = model
|
||||
@@ -83,6 +85,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
menuBar?.stop()
|
||||
}
|
||||
|
||||
/// Echte Widgets zuerst, danach die Platzhalter für alles, was noch fehlt.
|
||||
private func registerWidgets() {
|
||||
let calendarModel = CalendarModel(source: EventKitSource())
|
||||
self.calendarModel = calendarModel
|
||||
WidgetRegistry.shared.register(CalendarWidget(model: calendarModel))
|
||||
|
||||
PlaceholderWidgets.registerAll()
|
||||
}
|
||||
|
||||
private func applyContentSize() {
|
||||
guard let model, let coordinator else { return }
|
||||
let size = model.panelContentSize
|
||||
|
||||
@@ -39,10 +39,11 @@ struct PlaceholderWidget: OnyxWidget {
|
||||
|
||||
@MainActor
|
||||
enum PlaceholderWidgets {
|
||||
/// Registriert nur noch die Karten, deren echte Fassung fehlt. Sobald ein
|
||||
/// Widget umgesetzt ist, verschwindet es hier — die Kennung bleibt gleich,
|
||||
/// damit gespeicherte Layouts weitergelten.
|
||||
static func registerAll() {
|
||||
let widgets: [PlaceholderWidget] = [
|
||||
.init(id: "calendar", displayName: "Kalender", symbolName: "calendar",
|
||||
supportedSizes: [.medium, .large], phase: "Phase 3"),
|
||||
.init(id: "weather", displayName: "Wetter", symbolName: "cloud.sun",
|
||||
supportedSizes: [.small, .medium], phase: "Phase 3"),
|
||||
.init(id: "media", displayName: "Medien", symbolName: "play.circle",
|
||||
|
||||
@@ -13,6 +13,7 @@ let package = Package(
|
||||
.library(name: "OnyxNotch", targets: ["OnyxNotch"]),
|
||||
.library(name: "OnyxWidgetKit", targets: ["OnyxWidgetKit"]),
|
||||
.library(name: "OnyxMenuBar", targets: ["OnyxMenuBar"]),
|
||||
.library(name: "CalendarProvider", targets: ["CalendarProvider"]),
|
||||
],
|
||||
targets: [
|
||||
// Der String-Katalog muss ausdrücklich als Ressource stehen — sonst gibt
|
||||
@@ -31,5 +32,9 @@ let package = Package(
|
||||
|
||||
.target(name: "OnyxMenuBar", dependencies: ["OnyxDesign", "OnyxWidgetKit"]),
|
||||
.testTarget(name: "OnyxMenuBarTests", dependencies: ["OnyxMenuBar"]),
|
||||
|
||||
.target(name: "CalendarProvider", dependencies: ["OnyxDesign", "OnyxWidgetKit"],
|
||||
resources: [.process("Localizable.xcstrings")]),
|
||||
.testTarget(name: "CalendarProviderTests", dependencies: ["CalendarProvider"]),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import Foundation
|
||||
|
||||
/// Ein Termin, unabhängig davon, woher er stammt.
|
||||
public struct OnyxEvent: Identifiable, Equatable, Sendable {
|
||||
public let id: String
|
||||
public let title: String
|
||||
public let start: Date
|
||||
public let end: Date
|
||||
public let isAllDay: Bool
|
||||
/// `#RRGGBB` der Quelle. Kalenderfarben sind die eine Stelle, an der Onyx
|
||||
/// fremde Farben übernimmt statt eigene zu setzen — sonst erkennt man
|
||||
/// seine Kalender nicht wieder.
|
||||
public let colorHex: String?
|
||||
public let location: String?
|
||||
public let calendarID: String
|
||||
public let calendarTitle: String
|
||||
|
||||
public init(id: String, title: String, start: Date, end: Date, isAllDay: Bool,
|
||||
colorHex: String?, location: String?,
|
||||
calendarID: String, calendarTitle: String) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.start = start
|
||||
self.end = end
|
||||
self.isAllDay = isAllDay
|
||||
self.colorHex = colorHex
|
||||
self.location = location
|
||||
self.calendarID = calendarID
|
||||
self.calendarTitle = calendarTitle
|
||||
}
|
||||
}
|
||||
|
||||
public struct OnyxCalendar: Identifiable, Equatable, Sendable {
|
||||
public let id: String
|
||||
public let title: String
|
||||
public let colorHex: String?
|
||||
|
||||
public init(id: String, title: String, colorHex: String?) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.colorHex = colorHex
|
||||
}
|
||||
}
|
||||
|
||||
/// Termine **samt der Angabe, wofür überhaupt Daten vorliegen.**
|
||||
///
|
||||
/// Das ist der Kern und nicht ein Detail: ein Kalender ohne Termine und ein
|
||||
/// Kalender, über den nichts bekannt ist, sehen in einer Liste identisch aus —
|
||||
/// nämlich leer. Ein leerer März ist überzeugend und schlicht falsch, wenn die
|
||||
/// Quelle nur 49 Tage abdeckt. `coverage` trennt beides.
|
||||
public struct EventWindow: Equatable, Sendable {
|
||||
public let events: [OnyxEvent]
|
||||
/// Zeitraum mit gesicherter Information. `nil` bedeutet unbegrenzt —
|
||||
/// so verhält sich der Apple-Kalender, weil er direkt abgefragt wird.
|
||||
public let coverage: DateInterval?
|
||||
/// Stand der Daten, falls die Quelle zwischengespeichert liefert.
|
||||
public let asOf: Date?
|
||||
|
||||
public init(events: [OnyxEvent], coverage: DateInterval? = nil, asOf: Date? = nil) {
|
||||
self.events = events
|
||||
self.coverage = coverage
|
||||
self.asOf = asOf
|
||||
}
|
||||
|
||||
public static let empty = EventWindow(events: [])
|
||||
}
|
||||
|
||||
/// Warum gerade keine Termine da sind. Jeder Fall braucht eine eigene Antwort
|
||||
/// in der Oberfläche — „nichts anzuzeigen" ist keine.
|
||||
public enum CalendarSourceState: Equatable, Sendable {
|
||||
case ready(EventWindow)
|
||||
/// Berechtigung nicht erteilt.
|
||||
case denied
|
||||
/// Die Quelle wurde eingerichtet, hat aber noch nie geschrieben.
|
||||
case neverWritten
|
||||
/// Angemeldet sein wäre nötig.
|
||||
case loggedOut(asOf: Date?)
|
||||
/// Die Quelle ist neuer als das, was Onyx lesen kann.
|
||||
case incompatible(found: Int, maxSupported: Int)
|
||||
case unreadable(reason: String)
|
||||
}
|
||||
|
||||
/// Eine Quelle für Termine. Apple-Kalender und Calendarr liefern beide dies.
|
||||
public protocol CalendarSource: Sendable {
|
||||
var id: String { get }
|
||||
func requestAccess() async -> Bool
|
||||
func calendars() async -> [OnyxCalendar]
|
||||
func load(_ interval: DateInterval) async -> CalendarSourceState
|
||||
}
|
||||
296
Packages/OnyxKit/Sources/CalendarProvider/CalendarWidget.swift
Normal file
296
Packages/OnyxKit/Sources/CalendarProvider/CalendarWidget.swift
Normal file
@@ -0,0 +1,296 @@
|
||||
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<Void, Never>?
|
||||
|
||||
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[..<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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import Foundation
|
||||
import EventKit
|
||||
import AppKit
|
||||
|
||||
/// Termine aus dem Apple-Kalender. Ausschließlich lesend.
|
||||
public final class EventKitSource: CalendarSource, @unchecked Sendable {
|
||||
|
||||
public let id = "eventkit"
|
||||
private let store = EKEventStore()
|
||||
|
||||
/// Kalender, die der Nutzer ausgeblendet hat. Leer heißt: alle anzeigen.
|
||||
public var excludedCalendarIDs: Set<String> = []
|
||||
|
||||
public init() {}
|
||||
|
||||
public func requestAccess() async -> Bool {
|
||||
switch EKEventStore.authorizationStatus(for: .event) {
|
||||
case .fullAccess:
|
||||
return true
|
||||
case .denied, .restricted:
|
||||
return false
|
||||
case .notDetermined, .writeOnly:
|
||||
// `writeOnly` ist für uns wertlos — Onyx zeigt nur an. Der Nutzer
|
||||
// muss den Vollzugriff geben, sonst bleibt das Widget leer.
|
||||
return (try? await store.requestFullAccessToEvents()) ?? false
|
||||
@unknown default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
public func calendars() async -> [OnyxCalendar] {
|
||||
guard EKEventStore.authorizationStatus(for: .event) == .fullAccess else { return [] }
|
||||
return store.calendars(for: .event).map {
|
||||
OnyxCalendar(id: $0.calendarIdentifier,
|
||||
title: $0.title,
|
||||
colorHex: $0.cgColor.map(Self.hex(from:)))
|
||||
}
|
||||
}
|
||||
|
||||
public func load(_ interval: DateInterval) async -> CalendarSourceState {
|
||||
guard EKEventStore.authorizationStatus(for: .event) == .fullAccess else {
|
||||
return .denied
|
||||
}
|
||||
|
||||
let calendars = store.calendars(for: .event)
|
||||
.filter { !excludedCalendarIDs.contains($0.calendarIdentifier) }
|
||||
|
||||
// Ohne Kalender liefert EventKit alle statt keiner — der Unterschied
|
||||
// zwischen „nichts ausgewählt" und „nicht gefiltert" geht sonst verloren.
|
||||
guard !calendars.isEmpty else {
|
||||
return .ready(EventWindow(events: [], coverage: interval))
|
||||
}
|
||||
|
||||
let predicate = store.predicateForEvents(withStart: interval.start,
|
||||
end: interval.end,
|
||||
calendars: calendars)
|
||||
let events = store.events(matching: predicate).map(Self.convert)
|
||||
|
||||
// EventKit wird direkt abgefragt: was hier zurückkommt, ist vollständig.
|
||||
// Die Abdeckung ist deshalb genau der angefragte Zeitraum — anders als
|
||||
// bei Calendarr, das aus einem begrenzten Zwischenspeicher liest.
|
||||
return .ready(EventWindow(events: events.sorted { $0.start < $1.start },
|
||||
coverage: interval,
|
||||
asOf: Date()))
|
||||
}
|
||||
|
||||
// MARK: - Umwandlung
|
||||
|
||||
private static func convert(_ event: EKEvent) -> OnyxEvent {
|
||||
OnyxEvent(
|
||||
// `eventIdentifier` ist bei Serienterminen für alle Vorkommen
|
||||
// gleich. Zusammen mit dem Beginn wird daraus eine eindeutige
|
||||
// Kennung — sonst kollabiert eine Terminliste mit einer täglichen
|
||||
// Serie auf einen einzigen Eintrag.
|
||||
id: "\(event.eventIdentifier ?? UUID().uuidString)@\(event.startDate.timeIntervalSince1970)",
|
||||
title: event.title ?? "",
|
||||
start: event.startDate,
|
||||
end: event.endDate,
|
||||
isAllDay: event.isAllDay,
|
||||
colorHex: event.calendar.cgColor.map(hex(from:)),
|
||||
location: event.location?.isEmpty == false ? event.location : nil,
|
||||
calendarID: event.calendar.calendarIdentifier,
|
||||
calendarTitle: event.calendar.title)
|
||||
}
|
||||
|
||||
private static func hex(from color: CGColor) -> String {
|
||||
guard let converted = NSColor(cgColor: color)?.usingColorSpace(.sRGB) else {
|
||||
return "#8E8E93"
|
||||
}
|
||||
return String(format: "#%02X%02X%02X",
|
||||
Int(round(converted.redComponent * 255)),
|
||||
Int(round(converted.greenComponent * 255)),
|
||||
Int(round(converted.blueComponent * 255)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"sourceLanguage" : "en",
|
||||
"strings" : {
|
||||
"widget.calendar.name" : {
|
||||
"localizations" : {
|
||||
"de" : { "stringUnit" : { "state" : "translated", "value" : "Kalender" } },
|
||||
"en" : { "stringUnit" : { "state" : "translated", "value" : "Calendar" } }
|
||||
}
|
||||
},
|
||||
"widget.calendar.noUpcoming" : {
|
||||
"comment" : "Nur zeigen, wenn der Zeitraum wirklich abgedeckt ist",
|
||||
"localizations" : {
|
||||
"de" : { "stringUnit" : { "state" : "translated", "value" : "Nichts mehr für heute" } },
|
||||
"en" : { "stringUnit" : { "state" : "translated", "value" : "Nothing left today" } }
|
||||
}
|
||||
},
|
||||
"widget.calendar.allDay" : {
|
||||
"localizations" : {
|
||||
"de" : { "stringUnit" : { "state" : "translated", "value" : "Ganztägig" } },
|
||||
"en" : { "stringUnit" : { "state" : "translated", "value" : "All day" } }
|
||||
}
|
||||
},
|
||||
"widget.calendar.day.unknown" : {
|
||||
"comment" : "Erklärt einen ausgegrauten Tag: keine Information, nicht keine Termine",
|
||||
"localizations" : {
|
||||
"de" : { "stringUnit" : { "state" : "translated", "value" : "Für diesen Tag liegen keine Daten vor" } },
|
||||
"en" : { "stringUnit" : { "state" : "translated", "value" : "No data available for this day" } }
|
||||
}
|
||||
},
|
||||
"widget.calendar.denied" : {
|
||||
"localizations" : {
|
||||
"de" : { "stringUnit" : { "state" : "translated", "value" : "Kein Kalenderzugriff.\nIn den Systemeinstellungen erlauben." } },
|
||||
"en" : { "stringUnit" : { "state" : "translated", "value" : "No calendar access.\nAllow it in System Settings." } }
|
||||
}
|
||||
},
|
||||
"widget.calendar.neverWritten" : {
|
||||
"localizations" : {
|
||||
"de" : { "stringUnit" : { "state" : "translated", "value" : "Calendarr einmal öffnen,\ndamit Termine bereitstehen." } },
|
||||
"en" : { "stringUnit" : { "state" : "translated", "value" : "Open Calendarr once\nso events become available." } }
|
||||
}
|
||||
},
|
||||
"widget.calendar.loggedOut" : {
|
||||
"localizations" : {
|
||||
"de" : { "stringUnit" : { "state" : "translated", "value" : "In Calendarr anmelden" } },
|
||||
"en" : { "stringUnit" : { "state" : "translated", "value" : "Sign in to Calendarr" } }
|
||||
}
|
||||
},
|
||||
"widget.calendar.incompatible" : {
|
||||
"localizations" : {
|
||||
"de" : { "stringUnit" : { "state" : "translated", "value" : "Onyx ist zu alt für diese\nCalendarr-Version. Bitte aktualisieren." } },
|
||||
"en" : { "stringUnit" : { "state" : "translated", "value" : "Onyx is too old for this\nCalendarr version. Please update." } }
|
||||
}
|
||||
},
|
||||
"widget.calendar.unreadable" : {
|
||||
"localizations" : {
|
||||
"de" : { "stringUnit" : { "state" : "translated", "value" : "Termine nicht lesbar.\nKonfigurationsfehler." } },
|
||||
"en" : { "stringUnit" : { "state" : "translated", "value" : "Events unreadable.\nConfiguration error." } }
|
||||
}
|
||||
}
|
||||
},
|
||||
"version" : "1.0"
|
||||
}
|
||||
104
Packages/OnyxKit/Sources/CalendarProvider/MonthGrid.swift
Normal file
104
Packages/OnyxKit/Sources/CalendarProvider/MonthGrid.swift
Normal file
@@ -0,0 +1,104 @@
|
||||
import Foundation
|
||||
|
||||
/// Was über einen Tag bekannt ist.
|
||||
public enum DayStatus: Equatable, Sendable {
|
||||
/// Keine Termine — und das ist gesicherte Information.
|
||||
case free
|
||||
/// Mindestens ein Termin.
|
||||
case busy
|
||||
/// Die Quelle deckt diesen Tag nicht ab. **Nicht dasselbe wie frei.**
|
||||
case unknown
|
||||
}
|
||||
|
||||
/// Ein Tag im Monatsraster.
|
||||
public struct MonthDay: Identifiable, Equatable, Sendable {
|
||||
public let date: Date
|
||||
/// Gehört der Tag zum dargestellten Monat oder füllt er nur die Woche auf?
|
||||
public let isInMonth: Bool
|
||||
|
||||
public var id: Date { date }
|
||||
}
|
||||
|
||||
/// Das Raster eines Monats, immer aus vollen Wochen.
|
||||
///
|
||||
/// Die Wochen füllen sich mit Tagen aus den Nachbarmonaten auf. Eine
|
||||
/// angeschnittene erste Zeile würde die Spaltenausrichtung brechen, und dann
|
||||
/// steht der Dienstag nicht mehr unter „Di".
|
||||
public struct MonthGrid: Equatable, Sendable {
|
||||
|
||||
public let month: Date
|
||||
public let days: [MonthDay]
|
||||
private let calendar: Calendar
|
||||
|
||||
public init(containing date: Date, calendar: Calendar) {
|
||||
self.calendar = calendar
|
||||
|
||||
let startOfMonth = calendar.date(from: calendar.dateComponents([.year, .month], from: date))!
|
||||
self.month = startOfMonth
|
||||
|
||||
let dayCount = calendar.range(of: .day, in: .month, for: startOfMonth)!.count
|
||||
|
||||
// Wie viele Tage aus dem Vormonat vorne stehen müssen, damit die erste
|
||||
// Zeile am eingestellten Wochenanfang beginnt.
|
||||
let weekdayOfFirst = calendar.component(.weekday, from: startOfMonth)
|
||||
let leading = (weekdayOfFirst - calendar.firstWeekday + 7) % 7
|
||||
|
||||
var days: [MonthDay] = []
|
||||
for offset in 0..<leading {
|
||||
let d = calendar.date(byAdding: .day, value: offset - leading, to: startOfMonth)!
|
||||
days.append(MonthDay(date: calendar.startOfDay(for: d), isInMonth: false))
|
||||
}
|
||||
for offset in 0..<dayCount {
|
||||
let d = calendar.date(byAdding: .day, value: offset, to: startOfMonth)!
|
||||
days.append(MonthDay(date: calendar.startOfDay(for: d), isInMonth: true))
|
||||
}
|
||||
// Hinten auf volle Wochen auffüllen.
|
||||
while days.count % 7 != 0 {
|
||||
let d = calendar.date(byAdding: .day, value: 1, to: days.last!.date)!
|
||||
days.append(MonthDay(date: calendar.startOfDay(for: d), isInMonth: false))
|
||||
}
|
||||
self.days = days
|
||||
}
|
||||
|
||||
/// Was über diesen Tag gesagt werden darf.
|
||||
///
|
||||
/// Die Abdeckung entscheidet zuerst. Liegt der Tag außerhalb, ist das
|
||||
/// Ergebnis `unknown` — auch dann, wenn Termine dafür geliefert wurden.
|
||||
/// Andernfalls behauptet die Anzeige mehr zu wissen, als die Quelle belegt.
|
||||
public func status(for date: Date,
|
||||
coverage: DateInterval?,
|
||||
busyDays: Set<Date>) -> DayStatus {
|
||||
let day = calendar.startOfDay(for: date)
|
||||
|
||||
if let coverage {
|
||||
// Halboffenes Intervall [start, end) — so legt es die
|
||||
// Calendarr-Anleitung fest, und `DateInterval.contains` schließt
|
||||
// das Ende ein, deshalb der eigene Vergleich.
|
||||
guard day >= calendar.startOfDay(for: coverage.start),
|
||||
day < calendar.startOfDay(for: coverage.end) else { return .unknown }
|
||||
}
|
||||
return busyDays.contains(day) ? .busy : .free
|
||||
}
|
||||
|
||||
public static func == (lhs: MonthGrid, rhs: MonthGrid) -> Bool {
|
||||
lhs.month == rhs.month && lhs.days == rhs.days
|
||||
}
|
||||
}
|
||||
|
||||
public extension Collection where Element == OnyxEvent {
|
||||
/// Die Tage, an denen etwas stattfindet — für das Monatsraster.
|
||||
/// Mehrtägige Termine markieren jeden berührten Tag.
|
||||
func busyDays(calendar: Calendar) -> Set<Date> {
|
||||
var days = Set<Date>()
|
||||
for event in self {
|
||||
var day = calendar.startOfDay(for: event.start)
|
||||
let last = calendar.startOfDay(for: event.end)
|
||||
while day <= last {
|
||||
days.insert(day)
|
||||
guard let next = calendar.date(byAdding: .day, value: 1, to: day) else { break }
|
||||
day = next
|
||||
}
|
||||
}
|
||||
return days
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import CalendarProvider
|
||||
|
||||
@Suite("Monatsraster")
|
||||
struct MonthGridTests {
|
||||
|
||||
private var calendar: Calendar {
|
||||
var c = Calendar(identifier: .gregorian)
|
||||
c.timeZone = TimeZone(identifier: "Europe/Berlin")!
|
||||
c.firstWeekday = 2 // Montag
|
||||
return c
|
||||
}
|
||||
|
||||
private func date(_ string: String) -> Date {
|
||||
let f = DateFormatter()
|
||||
f.calendar = calendar
|
||||
f.timeZone = calendar.timeZone
|
||||
f.dateFormat = "yyyy-MM-dd"
|
||||
return f.date(from: string)!
|
||||
}
|
||||
|
||||
// MARK: - Aufbau
|
||||
|
||||
@Test("August 2026 beginnt an einem Samstag und braucht deshalb fünf Vortage")
|
||||
func leadingDaysForAugust2026() {
|
||||
let grid = MonthGrid(containing: date("2026-08-10"), calendar: calendar)
|
||||
|
||||
// 1. August 2026 ist ein Samstag; bei Wochenstart Montag stehen davor
|
||||
// Mo–Fr aus dem Juli.
|
||||
#expect(grid.days.first?.date == date("2026-07-27"))
|
||||
#expect(grid.days.prefix(5).allSatisfy { !$0.isInMonth })
|
||||
}
|
||||
|
||||
@Test("Das Raster besteht immer aus vollen Wochen")
|
||||
func gridIsWholeWeeks() {
|
||||
for month in ["2026-01-15", "2026-02-15", "2026-08-10", "2026-12-01"] {
|
||||
let grid = MonthGrid(containing: date(month), calendar: calendar)
|
||||
#expect(grid.days.count % 7 == 0, "Monat \(month) ergibt keine vollen Wochen")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Februar 2027 hat 28 Tage im Monat, egal wie viele Zellen das Raster hat")
|
||||
func februaryHasCorrectDayCount() {
|
||||
let grid = MonthGrid(containing: date("2027-02-10"), calendar: calendar)
|
||||
#expect(grid.days.filter(\.isInMonth).count == 28)
|
||||
}
|
||||
|
||||
@Test("Ein Schaltjahr wird korrekt behandelt")
|
||||
func leapYear() {
|
||||
let grid = MonthGrid(containing: date("2028-02-10"), calendar: calendar)
|
||||
#expect(grid.days.filter(\.isInMonth).count == 29)
|
||||
}
|
||||
|
||||
@Test("Bei Wochenstart Sonntag verschiebt sich die erste Zeile")
|
||||
func sundayStart() {
|
||||
var sundayFirst = calendar
|
||||
sundayFirst.firstWeekday = 1
|
||||
let grid = MonthGrid(containing: date("2026-08-10"), calendar: sundayFirst)
|
||||
|
||||
#expect(grid.days.first?.date == date("2026-07-26"))
|
||||
}
|
||||
|
||||
@Test("Tage außerhalb des Monats sind als solche markiert")
|
||||
func outOfMonthDaysAreMarked() {
|
||||
let grid = MonthGrid(containing: date("2026-08-10"), calendar: calendar)
|
||||
let inMonth = grid.days.filter(\.isInMonth)
|
||||
|
||||
#expect(inMonth.count == 31)
|
||||
#expect(grid.days.count > inMonth.count)
|
||||
}
|
||||
|
||||
// MARK: - Abdeckung: der Unterschied zwischen „frei" und „unbekannt"
|
||||
|
||||
@Test("Ohne Abdeckungsgrenze gilt jeder Tag als bekannt")
|
||||
func unlimitedCoverageKnowsEverything() {
|
||||
let grid = MonthGrid(containing: date("2026-08-10"), calendar: calendar)
|
||||
let status = grid.status(for: date("2026-08-10"), coverage: nil, busyDays: [])
|
||||
|
||||
#expect(status == .free)
|
||||
}
|
||||
|
||||
@Test("Ein Tag innerhalb der Abdeckung ohne Termine ist frei")
|
||||
func dayInCoverageWithoutEventsIsFree() {
|
||||
let grid = MonthGrid(containing: date("2026-08-10"), calendar: calendar)
|
||||
let coverage = DateInterval(start: date("2026-08-01"), end: date("2026-09-01"))
|
||||
|
||||
#expect(grid.status(for: date("2026-08-10"), coverage: coverage, busyDays: []) == .free)
|
||||
}
|
||||
|
||||
@Test("Ein Tag AUSSERHALB der Abdeckung ist unbekannt, nicht frei")
|
||||
func dayOutsideCoverageIsUnknown() {
|
||||
// Der Kernpunkt. Ein leerer März sieht überzeugend echt aus und ist
|
||||
// falsch, wenn die Quelle dort gar nichts weiß.
|
||||
let grid = MonthGrid(containing: date("2026-08-10"), calendar: calendar)
|
||||
let coverage = DateInterval(start: date("2026-08-01"), end: date("2026-08-15"))
|
||||
|
||||
#expect(grid.status(for: date("2026-08-20"), coverage: coverage, busyDays: []) == .unknown)
|
||||
}
|
||||
|
||||
@Test("Der letzte Tag der Abdeckung gehört nicht mehr dazu")
|
||||
func coverageEndIsExclusive() {
|
||||
// [start, end) — so ist es in der Calendarr-Anleitung festgelegt.
|
||||
let grid = MonthGrid(containing: date("2026-08-10"), calendar: calendar)
|
||||
let coverage = DateInterval(start: date("2026-08-01"), end: date("2026-08-15"))
|
||||
|
||||
#expect(grid.status(for: date("2026-08-14"), coverage: coverage, busyDays: []) == .free)
|
||||
#expect(grid.status(for: date("2026-08-15"), coverage: coverage, busyDays: []) == .unknown)
|
||||
}
|
||||
|
||||
@Test("Ein Tag vor Beginn der Abdeckung ist ebenfalls unbekannt")
|
||||
func dayBeforeCoverageIsUnknown() {
|
||||
let grid = MonthGrid(containing: date("2026-08-10"), calendar: calendar)
|
||||
let coverage = DateInterval(start: date("2026-08-05"), end: date("2026-08-30"))
|
||||
|
||||
#expect(grid.status(for: date("2026-08-01"), coverage: coverage, busyDays: []) == .unknown)
|
||||
}
|
||||
|
||||
@Test("Ein Tag mit Terminen ist belegt")
|
||||
func dayWithEventsIsBusy() {
|
||||
let grid = MonthGrid(containing: date("2026-08-10"), calendar: calendar)
|
||||
let busy: Set<Date> = [calendar.startOfDay(for: date("2026-08-10"))]
|
||||
|
||||
#expect(grid.status(for: date("2026-08-10"), coverage: nil, busyDays: busy) == .busy)
|
||||
}
|
||||
|
||||
@Test("Termine außerhalb der Abdeckung ändern nichts — sie dürften gar nicht existieren")
|
||||
func busyOutsideCoverageStaysUnknown() {
|
||||
// Widersprüchliche Eingabe: die Quelle liefert einen Termin für einen
|
||||
// Tag, den sie nach eigener Angabe nicht abdeckt. Die Abdeckung gewinnt,
|
||||
// sonst behauptet die Anzeige mehr zu wissen, als belegt ist.
|
||||
let grid = MonthGrid(containing: date("2026-08-10"), calendar: calendar)
|
||||
let coverage = DateInterval(start: date("2026-08-01"), end: date("2026-08-15"))
|
||||
let busy: Set<Date> = [calendar.startOfDay(for: date("2026-08-20"))]
|
||||
|
||||
#expect(grid.status(for: date("2026-08-20"), coverage: coverage, busyDays: busy) == .unknown)
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,8 @@ targets:
|
||||
product: OnyxWidgetKit
|
||||
- package: OnyxKit
|
||||
product: OnyxMenuBar
|
||||
- package: OnyxKit
|
||||
product: CalendarProvider
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.scarriffleservices.onyx
|
||||
|
||||
Reference in New Issue
Block a user