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