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:
Scarriffle
2026-08-13 14:00:48 +02:00
parent ab96086db7
commit 49e1a46928
10 changed files with 1997 additions and 1416 deletions

View File

@@ -67,3 +67,18 @@ public extension EventWindow {
return EventWindow(events: events, coverage: coverage, asOf: asOf)
}
}
public extension CalendarSourceState {
/// Blendet die abgewählten Kalender aus.
///
/// Die Abdeckung bleibt unangetastet: welche Kalender man sehen will, sagt
/// nichts darüber aus, für welchen Zeitraum Daten vorliegen. Ein Tag ohne
/// sichtbare Termine bleibt ein bekannter Tag.
func filteringHidden(_ hidden: Set<String>) -> CalendarSourceState {
guard case .ready(let window) = self, !hidden.isEmpty else { return self }
return .ready(EventWindow(
events: window.events.filter { !hidden.contains($0.calendarID) },
coverage: window.coverage,
asOf: window.asOf))
}
}

View File

@@ -29,7 +29,24 @@ public final class CalendarModel {
didSet { defaults.set(showsMonthGrid, forKey: Self.gridKey) }
}
/// Welche Kalender Daten liefern. `nil` heißt: alle.
///
/// Bewusst eine Liste der **abgewählten**, nicht der gewählten: kommt ein
/// neuer Kalender dazu, ist er dann automatisch dabei. Andersherum müsste
/// man ihn suchen und anhaken, ohne zu wissen, dass es ihn gibt.
public var hiddenCalendarIDs: Set<String> {
didSet {
guard hiddenCalendarIDs != oldValue else { return }
defaults.set(Array(hiddenCalendarIDs), forKey: Self.hiddenKey)
refresh()
}
}
/// Die Kalender, die zur Auswahl stehen.
public private(set) var availableCalendars: [OnyxCalendar] = []
private static let kindKey = "calendar.source"
private static let hiddenKey = "calendar.hiddenCalendars"
private static let gridKey = "calendar.showsMonthGrid"
private let appleSource: CalendarSource
@@ -51,6 +68,7 @@ public final class CalendarModel {
self.kind = defaults.string(forKey: Self.kindKey)
.flatMap(CalendarSourceKind.init(rawValue:)) ?? .appleCalendar
self.showsMonthGrid = defaults.bool(forKey: Self.gridKey)
self.hiddenCalendarIDs = Set(defaults.stringArray(forKey: Self.hiddenKey) ?? [])
observeCalendarrIfNeeded()
}
@@ -139,7 +157,16 @@ public final class CalendarModel {
states.append(source.isAuthorized ? await source.load(interval) : .denied)
}
guard !Task.isCancelled else { return }
state = CalendarSourceKind.merge(states)
state = CalendarSourceKind.merge(states).filteringHidden(hiddenCalendarIDs)
// Die Liste der wählbaren Kalender nebenbei mitführen sie ändert
// sich, wenn jemand in Kalender einen anlegt oder die Quelle
// wechselt.
var found: [OnyxCalendar] = []
for source in activeSources where source.isAuthorized {
found.append(contentsOf: await source.calendars())
}
availableCalendars = found
}
}

View File

@@ -1,166 +1,167 @@
{
"sourceLanguage": "en",
"strings": {
"airdrop.hint": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Zum Senden ablegen"
"sourceLanguage" : "en",
"strings" : {
"airdrop.hint" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Zum Senden ablegen"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Drop to send"
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Drop to send"
}
}
}
},
"airdrop.name": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "AirDrop"
"airdrop.name" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "AirDrop"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "AirDrop"
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "AirDrop"
}
}
}
},
"airdrop.unavailable": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "AirDrop kann das nicht senden"
"airdrop.unavailable" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "AirDrop kann das nicht senden"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "AirDrop cannot send this"
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "AirDrop cannot send this"
}
}
}
},
"shelf.airdrop": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Per AirDrop senden"
"shelf.airdrop" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Per AirDrop senden"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Send via AirDrop"
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Send via AirDrop"
}
}
}
},
"shelf.clear": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Alles leeren"
"shelf.clear" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Alles leeren"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Clear all"
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Clear all"
}
}
}
},
"shelf.delete": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "In den Papierkorb"
"shelf.delete" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "In den Papierkorb"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Move to Trash"
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Move to Trash"
}
}
}
},
"shelf.empty": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Dateien hierhin ziehen, um sie zu parken."
"shelf.empty" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Dateien hierhin ziehen, um sie zu parken."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Drop files here to park them."
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Drop files here to park them."
}
}
}
},
"shelf.name": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Ablage & AirDrop"
"shelf.name" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Ablage & AirDrop"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Shelf & AirDrop"
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Shelf & AirDrop"
}
}
}
},
"shelf.open": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Öffnen"
"shelf.open" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Öffnen"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Open"
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Open"
}
}
}
},
"shelf.reveal": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Im Finder zeigen"
"shelf.reveal" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Im Finder zeigen"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Show in Finder"
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Show in Finder"
}
}
}
}
},
"version": "1.1"
}
"version" : "1.1"
}

View File

@@ -174,6 +174,22 @@ public final class WeatherKitSource {
return fetched
}
/// Koordinaten zu einem Ortsnamen.
///
/// Mit Zeitlimit wie alles andere hier: eine Ortssuche ohne Netz wartet
/// sonst, bis jemand das Fenster schließt.
public func coordinates(for query: String) async -> (name: String,
coordinate: CLLocationCoordinate2D)? {
let found = await withTimeout(seconds: 8) {
try? await CLGeocoder().geocodeAddressString(query).first
} ?? nil
guard let placemark = found, let location = placemark.location else { return nil }
let name = [placemark.locality, placemark.administrativeArea, placemark.country]
.compactMap { $0 }
.first ?? query
return (name, location.coordinate)
}
private func name(for location: CLLocation) async -> String {
// Auch hier ein Zeitlimit: ohne Netz wartet die Umkehrsuche lange.
// `CLGeocoder` ist nicht `Sendable`, also wird es im Aufruf angelegt

View File

@@ -19,16 +19,30 @@ public final class WeatherModel {
/// liefert nur den Wert zum Zeitpunkt des Lesens.
public private(set) var locationAuthorization: LocationAuthorization = .undetermined
public var place: WeatherPlace {
didSet { guard place != oldValue else { return }; load(force: true) }
didSet {
guard place != oldValue else { return }
if let data = try? JSONEncoder().encode(place) {
defaults.set(data, forKey: Self.placeKey)
}
load(force: true)
}
}
private static let placeKey = "weather.place"
private let source: WeatherKitSource
private let defaults: UserDefaults
private var cache = WeatherCache()
private var task: Task<Void, Never>?
public init(source: WeatherKitSource = WeatherKitSource(), place: WeatherPlace = .current) {
public init(source: WeatherKitSource = WeatherKitSource(),
defaults: UserDefaults = .standard) {
self.source = source
self.place = place
self.defaults = defaults
// Der gewählte Ort überlebt den Neustart sonst steht nach jedem
// Start wieder das Wetter des Aufenthaltsorts da.
self.place = defaults.data(forKey: Self.placeKey)
.flatMap { try? JSONDecoder().decode(WeatherPlace.self, from: $0) } ?? .current
// Sofort zeigen, was zuletzt bekannt war. Ein Wert von vor einer Stunde
// mit Zeitstempel ist eine Auskunft; ein Rädchen ist keine.
if let known = cache.lastKnown(for: place) {
@@ -103,6 +117,23 @@ public final class WeatherModel {
task?.cancel()
task = nil
}
/// Sucht die Koordinaten zu einem Ortsnamen und übernimmt ihn.
///
/// WeatherKit will Koordinaten, der Nutzer kennt Ortsnamen. Die Umsetzung
/// dazwischen passiert einmal beim Einstellen, nicht bei jedem Abruf.
///
/// - Returns: der gefundene Ort, oder `nil` wenn es keinen gab.
@discardableResult
public func selectPlace(named query: String) async -> String? {
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
guard let found = await source.coordinates(for: trimmed) else { return nil }
place = .fixed(name: found.name,
latitude: found.coordinate.latitude,
longitude: found.coordinate.longitude)
return found.name
}
}
// MARK: - Widget