Files
onyx/Onyx/WidgetOptionsView.swift
Scarriffle 2c3b3cdd55 Einstellungen: die Linie unter den Reitern ist weg, und eine Fläche für alles
Die Linie kam von `TabView` selbst: es zieht unter seiner Leiste einen Trenner
quer durchs Fenster, und der lässt sich nicht abschalten. Der Abstand nach
oben, den ich beim letzten Mal eingebaut habe, hat deshalb nichts geändert —
der Trenner ist mitgewandert. Jetzt ist die Reiterleiste selbst gebaut, ein
Auswahlfeld über dem Inhalt, dann gibt es die Linie gar nicht erst.

Der zweite Punkt hat dieselbe Wurzel: die Formulare bringen ihren eigenen
Untergrund mit, der Rest des Fensters hat den des Systems. In Dunkelgrau
liegen die beiden ein, zwei Prozent auseinander — sichtbar als Kante quer
durchs Fenster, genau dort, wo das Formular anfängt. Der Untergrund der
Formulare ist jetzt abgeschaltet, und das ganze Fenster hat einen.

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

235 lines
8.0 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)
// 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 {
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)
}
}