Widget-Optionen statt „Quellen", Reiterleiste von der Trennlinie herunter
Die Leiste saß mittig auf der Trennlinie unter dem Titelbalken. `titlebarAppearsTransparent` allein reichte nicht — macOS zieht darunter zusätzlich einen Trenner quer durchs Fenster. Der ist jetzt aus. Man setzt keinen Titel auf eine Trennlinie. „Quellen" war die Sicht des Programmierers: für den Nutzer ist die Datenquelle eine Einstellung **des Kalenderwidgets** unter mehreren. Der Bereich heißt jetzt „Widget-Optionen" und hat einen Abschnitt je Widget. Kalender: woher die Daten kommen, Monatsraster oder Terminliste — und endlich, welche Kalender überhaupt mitzählen. Gespeichert werden die **abgewählten**, nicht die gewählten: kommt ein neuer Kalender dazu, ist er damit automatisch dabei, statt dass man ihn suchen müsste, ohne zu wissen, dass es ihn gibt. Der Filter lässt die Abdeckung unberührt — welche Kalender man sehen will, sagt nichts darüber aus, für welchen Zeitraum Daten vorliegen. Wetter: dem Standort folgen oder einen festen Ort setzen, per Stadt oder Postleitzahl. WeatherKit will Koordinaten, der Nutzer kennt Ortsnamen — die Umsetzung passiert einmal beim Einstellen statt bei jedem Abruf. Der Ort überlebt jetzt auch den Neustart; vorher stand nach jedem Start wieder der Aufenthaltsort da. Die Ablage-Einstellungen sind vom Widget-Reiter hierher gewandert, wo die anderen Widget-Optionen stehen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
230
Onyx/WidgetOptionsView.swift
Normal file
230
Onyx/WidgetOptionsView.swift
Normal file
@@ -0,0 +1,230 @@
|
||||
import SwiftUI
|
||||
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(.orange)
|
||||
case .unreadable(let reason):
|
||||
Text(reason).font(.callout).foregroundStyle(.orange)
|
||||
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(.orange)
|
||||
.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(.orange)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user