Files
onyx/Onyx/WidgetOptionsView.swift
Scarriffle 3c36092712 Knopf zum Nachsehen bei Calendarr
Onyx holt sich nichts, es liest den Schnappschuss und erfährt neue über eine
Darwin-Nachricht. Wer Calendarr gerade erst gestartet hat, will aber nicht auf
den nächsten Blick warten, sondern jetzt nachsehen.

Der Knopf steht in jedem Zustand, nicht nur wenn Termine da sind: gerade wenn
„noch nichts geschrieben" oder „bitte anmelden" dasteht, will man es nach dem
Beheben sofort noch einmal versuchen. Beim Laden zeigt er einen Kreisel, in
fester Breite — sonst zuckt die Zeile, weil der Kreisel schmaler ist als der
Pfeil.

Dabei aufgefallen: die Statustexte für „noch nichts geschrieben", „abgemeldet"
und „zu neues Format" standen ohne Übersetzung im Katalog. Wer in einen dieser
Zustände geriet, las den rohen Schlüssel. Jetzt übersetzt, dazu die zwei
Zustände, die gar nicht behandelt waren — kein Kalenderzugriff und „wird
gelesen".

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

274 lines
9.4 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,
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)
}
}