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:
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user