import SwiftUI import OnyxDesign import CalendarProvider import WeatherProvider import ShelfProvider /// Die Stellschrauben der einzelnen Widgets, an einem Ort. /// /// Vorher hieß der Bereich „Quellen" und enthielt nur die Kalenderquelle. Das /// war die Sicht des Programmierers: für den Nutzer ist die Quelle eine /// Einstellung **des Kalenderwidgets** unter mehreren — welche Kalender /// mitzählen, gehört genauso dazu, und beim Wetter der Ort. Ein Abschnitt je /// Widget ist die Ordnung, die man sucht. struct WidgetOptionsView: View { let calendarModel: CalendarModel let weatherModel: WeatherModel let shelfStore: ShelfStore? var body: some View { Form { Section("options.calendar") { CalendarOptions(model: calendarModel) } Section("options.weather") { WeatherOptions(model: weatherModel) } if let shelfStore { Section("options.shelf") { ShelfOptions(store: shelfStore) } } } .formStyle(.grouped) // Der eigene Untergrund des Formulars weicht ein, zwei Prozent von dem // des Fensters ab. In Dunkelgrau sieht man genau das als Kante. .scrollContentBackground(.hidden) .padding() } } // MARK: - Kalender private struct CalendarOptions: View { @Bindable var model: CalendarModel @State private var calendarrState: CalendarSourceState? var body: some View { Picker(selection: $model.kind) { ForEach(CalendarSourceKind.allCases, id: \.self) { kind in Text(kind.localizedName).tag(kind) } } label: { Text("options.calendar.source") } .pickerStyle(.menu) Picker(selection: $model.showsMonthGrid) { Text("options.calendar.agenda").tag(false) Text("options.calendar.month").tag(true) } label: { Text("options.calendar.display") } .pickerStyle(.menu) // Welche Kalender mitzählen. Gespeichert werden die **abgewählten**: // kommt ein neuer dazu, ist er automatisch dabei, statt dass man ihn // suchen muss, ohne zu wissen, dass es ihn gibt. if model.availableCalendars.isEmpty { Text("options.calendar.noneFound") .font(.callout).foregroundStyle(.secondary) } else { ForEach(model.availableCalendars) { calendar in Toggle(isOn: Binding( get: { !model.hiddenCalendarIDs.contains(calendar.id) }, set: { shown in if shown { model.hiddenCalendarIDs.remove(calendar.id) } else { model.hiddenCalendarIDs.insert(calendar.id) } })) { Label { Text(calendar.title) } icon: { Circle() .fill(Color(hex: calendar.colorHex) ?? .secondary) .frame(width: 9, height: 9) } } } } if model.kind != .appleCalendar { CalendarrServerSettings(model: model) CalendarrStatus(state: calendarrState, isLoading: model.isLoading, onRefresh: { model.refresh() }) .task(id: model.kind) { guard model.kind != .appleCalendar else { return } let now = Date() calendarrState = await CalendarrSource().load( DateInterval(start: now.addingTimeInterval(-14 * 86_400), end: now.addingTimeInterval(60 * 86_400))) } } } } /// Was Calendarr gerade liefert. Bleibt das Widget leer, steht hier das Warum. /// /// Mit einem Knopf zum Nachsehen. Onyx holt sich nichts — es liest den /// Schnappschuss, den Calendarr schreibt, und erfährt neue über eine /// Darwin-Nachricht. Die ist flüchtig: wer Calendarr gerade erst gestartet hat, /// will nicht auf den nächsten Blick warten, sondern jetzt nachsehen. private struct CalendarrStatus: View { let state: CalendarSourceState? let isLoading: Bool let onRefresh: () -> Void var body: some View { HStack(spacing: 8) { message Spacer(minLength: 8) if case .ready(let window) = state { Text(window.asOf?.formatted(date: .abbreviated, time: .shortened) ?? "–") .foregroundStyle(.secondary) } reloadButton } } @ViewBuilder private var message: some View { switch state { case .ready(let window): Text("options.calendar.calendarrEvents \(window.events.count)") case .neverWritten: Text("calendar.source.status.neverWritten").font(.callout).foregroundStyle(.secondary) case .loggedOut: Text("calendar.source.status.loggedOut").font(.callout).foregroundStyle(.secondary) case .incompatible: Text("calendar.source.status.incompatible").font(.callout) .foregroundStyle(Onyx.Color.warning) case .unreadable(let reason): Text(reason).font(.callout).foregroundStyle(Onyx.Color.warning) case .denied: Text("calendar.source.status.denied").font(.callout) .foregroundStyle(Onyx.Color.warning) case .none: Text("calendar.source.status.loading").font(.callout).foregroundStyle(.secondary) } } /// Feste Breite, damit die Zeile beim Laden nicht zuckt: der Kreisel ist /// nicht so breit wie der Pfeil. private var reloadButton: some View { Button(action: onRefresh) { Group { if isLoading { ProgressView().controlSize(.small) } else { Image(systemName: "arrow.clockwise") } } .frame(width: 16, height: 16) } .buttonStyle(.borderless) .disabled(isLoading) .help(Text("options.calendar.reload")) } } // MARK: - Wetter private struct WeatherOptions: View { let model: WeatherModel @State private var query = "" @State private var searching = false @State private var notFound = false private var usesCurrentLocation: Bool { model.place.isCurrent } var body: some View { Toggle(isOn: Binding( get: { usesCurrentLocation }, set: { model.place = $0 ? .current : model.place })) { Text("options.weather.automatic") } .disabled(usesCurrentLocation) if usesCurrentLocation { // Ohne Ortungsberechtigung nützt die Automatik nichts — und das // steht hier, statt dass das Widget stumm leer bleibt. if model.locationIsDenied { Text("options.weather.needsLocation") .font(.callout).foregroundStyle(Onyx.Color.warning) .fixedSize(horizontal: false, vertical: true) } } else if case .fixed(let name, _, _) = model.place { LabeledContent { Text(name).foregroundStyle(.secondary) } label: { Text("options.weather.place") } } HStack { TextField("options.weather.search", text: $query) .textFieldStyle(.roundedBorder) .onSubmit { Task { await search() } } if searching { ProgressView().controlSize(.small) } else { Button("options.weather.set") { Task { await search() } } .disabled(query.trimmingCharacters(in: .whitespaces).isEmpty) } } if notFound { Text("options.weather.notFound").font(.callout).foregroundStyle(Onyx.Color.warning) } } private func search() async { searching = true notFound = false defer { searching = false } if let found = await model.selectPlace(named: query) { query = "" _ = found } else { notFound = true } } } // MARK: - Ablage private struct ShelfOptions: View { @Bindable var store: ShelfStore private static let choices: [Int?] = [nil, 1, 7, 30, 90] var body: some View { Picker(selection: $store.autoCleanupDays) { ForEach(Self.choices, id: \.self) { days in if let days { Text("settings.shelf.days \(days)").tag(Optional(days)) } else { Text("settings.shelf.never").tag(Int?.none) } } } label: { Text("settings.shelf.cleanup") } HStack { Text("settings.shelf.count \(store.items.count)").foregroundStyle(.secondary) Spacer() Button("settings.shelf.reveal") { store.revealInFinder() } .buttonStyle(.borderless) } } } private extension Color { /// Die Farbe eines Kalenders, wie die Quelle sie liefert. init?(hex: String?) { guard let hex, hex.hasPrefix("#"), hex.count == 7, let value = Int(hex.dropFirst(), radix: 16) else { return nil } self.init(red: Double((value >> 16) & 0xFF) / 255, green: Double((value >> 8) & 0xFF) / 255, blue: Double(value & 0xFF) / 255) } } /// Die Anmeldung am Calendarr-Server. /// /// Der Schnappschuss im gemeinsamen Ordner ist nur so aktuell wie der letzte /// Start der Calendarr-App. Wer die Zugangsdaten hier einträgt, holt Onyx die /// Termine selbst — und dann ist es egal, ob Calendarr überhaupt läuft. /// /// Adresse und Benutzername stehen in den Einstellungen, das Passwort im /// Schlüsselbund. private struct CalendarrServerSettings: View { let model: CalendarModel @State private var credentials = CalendarrCredentials() @State private var server = "" @State private var username = "" @State private var password = "" @State private var totpCode = "" @State private var isChecking = false @State private var failure: String? private var account: CalendarrAccount? { credentials.account } var body: some View { if let account { LabeledContent { HStack(spacing: 10) { Text(account.username).foregroundStyle(.secondary) Button { credentials.clear() model.calendarrAccountChanged() } label: { Text("options.calendar.server.disconnect") } } } label: { Label { Text(account.server.host() ?? account.server.absoluteString) } icon: { Image(systemName: "checkmark.circle.fill") .foregroundStyle(Onyx.Color.positive) } } } else { // Drei Felder und ein Knopf. Geprüft wird beim Verbinden, nicht // erst beim nächsten Laden: ein Tippfehler, den man am Folgetag // bemerkt, ist ein vermeidbarer. TextField(text: $server) { Text("options.calendar.server.address") } .textContentType(.URL) TextField(text: $username) { Text("options.calendar.server.username") } .textContentType(.username) SecureField(text: $password) { Text("options.calendar.server.password") } .textContentType(.password) // Nur ausfüllen, wer einen zweiten Faktor hat. Ein Pflichtfeld // wäre es für alle anderen eine Frage, die sie nicht beantworten // können. TextField(text: $totpCode) { Text("options.calendar.server.totp") } .textContentType(.oneTimeCode) HStack { if let failure { Text(failure).font(.callout).foregroundStyle(Onyx.Color.warning) .fixedSize(horizontal: false, vertical: true) } else { Text("options.calendar.server.hint") .font(.caption).foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } Spacer(minLength: 12) Button(action: connect) { if isChecking { ProgressView().controlSize(.small) } else { Text("options.calendar.server.connect") } } .disabled(isChecking || server.isEmpty || username.isEmpty || password.isEmpty) } } } private func connect() { guard let url = CalendarrLiveDecoder.server(from: server) else { failure = CalendarrLiveError.badServer.errorDescription return } isChecking = true failure = nil Task { defer { isChecking = false } switch await CalendarrLiveSource.check(server: url, username: username, password: password, totpCode: totpCode) { case .success(let token): credentials.save(server: url, username: username, password: password, token: token) // Passwort und Code nicht im Formular stehen lassen — beide // liegen jetzt dort, wo sie hingehören. password = "" totpCode = "" model.calendarrAccountChanged() case .failure(let error): failure = error.localizedDescription } } } }