Files
onyx/Onyx/WidgetOptionsView.swift
Scarriffle 2aa60eedf9 Farben einstellbar — und erst mal überhaupt eine Farbe
Das Grün, das überall auftauchte, war die System-Akzentfarbe: `.tint` und
`.accentColor` werden ohne eigene Angabe dorthin durchgereicht, und das
betraf alle Popovers und das ganze Einstellungsfenster. Die Notch-Widgets
dagegen benutzten Onyx' eigenes Blau. Zwei Farben, ungewollt.

Jetzt eine, und die ist einstellbar. Ein neuer Reiter „Farben": Akzentfarbe
mit sechs Vorschlägen und einem Knopf, der die Systemfarbe übernimmt, dazu
das Paar für Herunter- und Hochladen samt Tauschknopf.

Die Signalfarben bleiben fest und stehen nur zur Ansicht dort. Gelb heißt
Warnung, Rot heißt kritisch, Grün heißt in Ordnung — bei Temperatur, Akku,
Lüfter und Fehlern. Wer sie umstellen kann, kann ihre Bedeutung zerstören;
ein rotes „alles gut" liest niemand richtig. Sichtbar sind sie trotzdem,
sonst sucht man die Warnfarbe im Reiter und findet sie nirgends.

Umgesetzt über die Tokens statt über vierzig Fundstellen: `Onyx.Color.accent`
ist keine Konstante mehr, sondern liest bei jedem Zugriff aus `OnyxTheme`.
Damit merkt SwiftUI die Abhängigkeit und zeichnet neu, sobald die Farbe sich
ändert. Dazu ein `.tint` an den vier Wurzeln — Panel, Popovers, Einstellungen,
Einrichtung —, damit auch Haken, Regler und Auswahlfelder mitziehen.

Nebenbei die Beschriftung: „Je Programm" heißt jetzt „Programme". Großgesetzt
las sich das als englisches „J-E".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 23:24:40 +02:00

232 lines
7.8 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 {
Form {
Section("options.calendar") {
CalendarOptions(model: calendarModel)
}
Section("options.weather") {
WeatherOptions(model: weatherModel)
}
if let shelfStore {
Section("options.shelf") {
ShelfOptions(store: shelfStore)
}
}
}
.formStyle(.grouped)
.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 {
CalendarrStatus(state: calendarrState)
.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.
private struct CalendarrStatus: View {
let state: CalendarSourceState?
var body: some View {
switch state {
case .ready(let window):
LabeledContent {
Text(window.asOf?.formatted(date: .abbreviated, time: .shortened) ?? "")
.foregroundStyle(.secondary)
} label: {
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, .none:
ProgressView().controlSize(.small)
}
}
}
// 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)
}
}