Files
onyx/Onyx/WidgetOptionsView.swift
T
ScarriffleandClaude Opus 5 edb327cabe Kacheln und Fenster sprechen dieselbe Sprache
Alle Kacheln, Popovers und Fenster auf die Designschicht umgestellt: rund
hundert gesetzte Schriftgrößen und 159 gesetzte Abstände und Radien sind jetzt
Token. Übrig bleibt `spacing: 0` — das ist eine Aussage, keine geratene Zahl.

Die Einstellungen bekommen eine Seitenleiste statt sieben Reitern, deren
deutsche Beschriftungen zusammen 880 Punkte brauchten. Panel-Token stehen nicht
mehr in Fenstern: dort gilt Systemsemantik, damit der Hellmodus nicht schwarz
auf schwarz endet.

Neu ist ein Schaufenster, das alle Bauteile nebeneinander zeigt — ohne das
lässt sich eine Änderung an einem Token nur prüfen, indem man die App bedient,
bis jede Kachel einmal zu sehen war.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 21:05:51 +02:00

379 lines
15 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 {
SettingsPage {
CalendarOptions(model: calendarModel)
WeatherOptions(model: weatherModel)
if let shelfStore {
ShelfOptions(store: shelfStore)
}
}
}
}
// MARK: - Kalender
private struct CalendarOptions: View {
@Bindable var model: CalendarModel
var body: some View {
OnyxFormCard(String(localized: "options.calendar")) {
OnyxFormRow(String(localized: "options.calendar.source")) {
Picker("", selection: $model.kind) {
ForEach(CalendarSourceKind.allCases, id: \.self) { kind in
Text(kind.localizedName).tag(kind)
}
}
.labelsHidden()
.frame(width: 180)
}
OnyxFormRow(String(localized: "options.calendar.display")) {
Picker("", selection: $model.showsMonthGrid) {
Text("options.calendar.agenda").tag(false)
Text("options.calendar.month").tag(true)
}
.labelsHidden()
.frame(width: 180)
}
}
OnyxFormCard(String(localized: "options.calendar.calendars"),
hint: String(localized: "options.calendar.calendars.hint")) {
if model.availableCalendars.isEmpty {
OnyxFormRow(String(localized: "options.calendar.noneFound"))
} else {
ForEach(model.availableCalendars) { calendar in
OnyxFormRow(calendar.title) {
Circle()
.fill(Color(hex: calendar.colorHex) ?? .secondary)
.frame(width: 9, height: 9)
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) }
}))
.labelsHidden()
}
}
}
}
if model.kind != .appleCalendar {
OnyxFormCard(String(localized: "options.calendar.serverGroup")) {
CalendarrServerSettings(model: model)
// Der Zustand kommt aus dem Modell, nicht aus einem zweiten
// eigenen Aufruf. Der las immer den Schnappschuss — auch wenn
// längst der Server gefragt wurde — und der Knopf daneben
// aktualisierte ihn nie.
CalendarrStatus(state: model.calendarrState,
usesServer: model.usesCalendarrServer,
isLoading: model.isLoading,
onRefresh: { model.refresh() })
}
}
}
}
/// Was Calendarr gerade liefert. Bleibt das Widget leer, steht hier das Warum.
///
/// Mit einem Knopf zum Nachsehen. Ohne Zugangsdaten liest Onyx 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. Mit
/// Zugangsdaten fragt Onyx den Server selbst — dann sagt diese Zeile, wann das
/// zuletzt gelang.
private struct CalendarrStatus: View {
let state: CalendarSourceState?
/// Ob gerade direkt vom Server gelesen wird oder aus dem Schnappschuss.
///
/// Ohne diese Auskunft ist der Zeitstempel daneben nicht zu deuten: ein
/// altes Datum heißt beim Schnappschuss „Calendarr lief lange nicht" und
/// beim Server „etwas stimmt nicht".
let usesServer: Bool
let isLoading: Bool
let onRefresh: () -> Void
var body: some View {
OnyxFormRow(String(localized: usesServer ? "options.calendar.viaServer"
: "options.calendar.viaSnapshot"),
hint: usesServer ? nil
: String(localized: "options.calendar.viaSnapshot.hint")) {
message
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 {
OnyxFormCard(String(localized: "options.weather")) {
OnyxFormRow(String(localized: "options.weather.automatic"),
// Ohne Ortungsberechtigung nützt die Automatik nichts —
// und das steht hier, statt dass das Widget stumm leer
// bleibt.
hint: usesCurrentLocation && model.locationIsDenied
? String(localized: "options.weather.needsLocation") : nil) {
Toggle("", isOn: Binding(
get: { usesCurrentLocation },
set: { model.place = $0 ? .current : model.place }))
.labelsHidden()
.disabled(usesCurrentLocation)
}
if case .fixed(let name, _, _) = model.place {
OnyxFormRow(String(localized: "options.weather.place")) {
Text(name).foregroundStyle(.secondary)
}
}
OnyxFormRow(String(localized: "options.weather.search"),
hint: notFound ? String(localized: "options.weather.notFound") : nil) {
TextField("", text: $query)
.textFieldStyle(.roundedBorder)
.frame(width: 160)
.onSubmit { Task { await search() } }
if searching {
ProgressView().controlSize(.small)
} else {
Button("options.weather.set") { Task { await search() } }
.disabled(query.trimmingCharacters(in: .whitespaces).isEmpty)
}
}
}
}
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 {
OnyxFormCard(String(localized: "options.shelf")) {
OnyxFormRow(String(localized: "settings.shelf.cleanup")) {
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)
}
}
}
.labelsHidden()
.frame(width: 180)
}
OnyxFormRow(String(localized: "options.shelf")) {
Text("settings.shelf.count \(store.items.count)")
.foregroundStyle(.secondary)
Button("settings.shelf.reveal") { store.revealInFinder() }
}
}
}
}
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: Onyx.Space.row) {
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
}
}
}
}