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>
300 lines
11 KiB
Swift
300 lines
11 KiB
Swift
import SwiftUI
|
|
import AppKit
|
|
import OnyxDesign
|
|
import OnyxWidgetKit
|
|
|
|
/// Der Berechtigungszustand der Ortung — als beobachtbarer Wert.
|
|
public enum LocationAuthorization: Equatable, Sendable {
|
|
case granted, denied, undetermined
|
|
}
|
|
|
|
/// Hält den Wetterstand für das Widget.
|
|
@MainActor
|
|
@Observable
|
|
public final class WeatherModel {
|
|
|
|
public private(set) var state: WeatherState = .idle
|
|
/// Beobachtbar, damit die Einstellungen mitbekommen, wenn der Nutzer
|
|
/// zustimmt. Eine Abfrage auf `CLLocationManager` täte das nicht — sie
|
|
/// 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 }
|
|
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(),
|
|
defaults: UserDefaults = .standard) {
|
|
self.source = source
|
|
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) {
|
|
state = .ready(known)
|
|
}
|
|
syncAuthorization()
|
|
source.onLocationAuthorizationChange = { [weak self] in
|
|
guard let self else { return }
|
|
syncAuthorization()
|
|
// Nach der Zustimmung sofort laden statt auf den nächsten Anlauf
|
|
// zu warten.
|
|
if locationAuthorization == .granted { load(force: true) }
|
|
}
|
|
}
|
|
|
|
private func syncAuthorization() {
|
|
locationAuthorization = if source.locationIsDenied { .denied }
|
|
else if source.locationIsUndetermined { .undetermined }
|
|
else { .granted }
|
|
}
|
|
|
|
public var locationIsDenied: Bool { locationAuthorization == .denied }
|
|
public var locationIsUndetermined: Bool { locationAuthorization == .undetermined }
|
|
public var locationIsAuthorized: Bool { locationAuthorization == .granted }
|
|
|
|
/// Fragt die Ortungsberechtigung an. Das Ergebnis kommt über den
|
|
/// Delegaten zurück und aktualisiert `locationAuthorization` von selbst.
|
|
public func requestLocationAuthorization() {
|
|
source.requestLocationAuthorization()
|
|
}
|
|
|
|
/// Lädt, wenn der Zwischenspeicher abgelaufen ist.
|
|
///
|
|
/// `force` gilt nur für einen Ortswechsel — nicht für jedes Öffnen des
|
|
/// Panels. WeatherKit rechnet nach Abrufen ab, und das Wetter ändert sich
|
|
/// nicht im Sekundentakt.
|
|
public func load(force: Bool = false) {
|
|
if !force, let fresh = cache.valid(for: place) {
|
|
state = .ready(fresh)
|
|
return
|
|
}
|
|
|
|
task?.cancel()
|
|
let place = place
|
|
task = Task { [weak self] in
|
|
guard let self else { return }
|
|
if case .ready = state {} else { state = .loading }
|
|
|
|
let result = await source.load(place)
|
|
guard !Task.isCancelled else { return }
|
|
|
|
switch result {
|
|
case .ready(let snapshot):
|
|
cache.store(snapshot, for: place)
|
|
try? cache.save()
|
|
state = .ready(snapshot)
|
|
case .unavailable(_, let reason):
|
|
// Der letzte bekannte Stand bleibt sichtbar. Ein alter Messwert
|
|
// mit Zeitstempel ist ehrlicher und nützlicher als ein leeres Feld.
|
|
state = .unavailable(lastKnown: cache.lastKnown(for: place), reason: reason)
|
|
case .loading, .idle:
|
|
// Kommt von der Quelle nie zurück; hier stehen zu bleiben wäre
|
|
// genau das Rädchen, das behoben werden sollte.
|
|
state = cache.lastKnown(for: place).map { .ready($0) } ?? result
|
|
default:
|
|
state = result
|
|
}
|
|
}
|
|
}
|
|
|
|
public func stop() {
|
|
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
|
|
|
|
public struct WeatherWidget: OnyxWidget {
|
|
public let id = "weather"
|
|
public var displayName: String { String(localized: "widget.weather.name", bundle: .module) }
|
|
public let symbolName = "cloud.sun"
|
|
|
|
private let model: WeatherModel
|
|
|
|
public init(model: WeatherModel) { self.model = model }
|
|
|
|
public func makeView() -> AnyView {
|
|
AnyView(WeatherWidgetView(model: model))
|
|
}
|
|
}
|
|
|
|
private struct WeatherWidgetView: View {
|
|
let model: WeatherModel
|
|
|
|
var body: some View {
|
|
Group {
|
|
switch model.state {
|
|
case .ready(let snapshot):
|
|
content(snapshot, isStale: false)
|
|
case .unavailable(let lastKnown, _):
|
|
if let lastKnown {
|
|
content(lastKnown, isStale: true)
|
|
} else {
|
|
Notice(symbol: "wifi.slash", text: "widget.weather.unavailable")
|
|
}
|
|
case .locationDenied:
|
|
Notice(symbol: "location.slash", text: "widget.weather.locationDenied")
|
|
case .locationUndetermined:
|
|
Notice(symbol: "location.circle", text: "widget.weather.locationUndetermined")
|
|
case .loading, .idle:
|
|
ProgressView()
|
|
.controlSize(.small)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
}
|
|
.task { model.load() }
|
|
.onDisappear { model.stop() }
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func content(_ snapshot: WeatherSnapshot, isStale: Bool) -> some View {
|
|
// Die Kachel ist hochkant und hat Platz für die ausführliche Fassung.
|
|
WideWeather(snapshot: snapshot, isStale: isStale)
|
|
}
|
|
}
|
|
|
|
private struct Notice: View {
|
|
let symbol: String
|
|
let text: LocalizedStringKey
|
|
|
|
var body: some View {
|
|
VStack(spacing: 6) {
|
|
Image(systemName: symbol)
|
|
.font(.system(size: 15))
|
|
.foregroundStyle(Onyx.Color.textTertiary)
|
|
Text(text, bundle: .module)
|
|
.font(Onyx.Font.caption)
|
|
.foregroundStyle(Onyx.Color.textSecondary)
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
}
|
|
|
|
private struct WideWeather: View {
|
|
let snapshot: WeatherSnapshot
|
|
let isStale: Bool
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
HStack(alignment: .top, spacing: 8) {
|
|
Image(systemName: snapshot.condition.symbolName(isDaylight: snapshot.isDaylight))
|
|
.font(.system(size: 22))
|
|
.symbolRenderingMode(.hierarchical)
|
|
.foregroundStyle(Onyx.Color.textPrimary)
|
|
|
|
VStack(alignment: .leading, spacing: 1) {
|
|
Text(snapshot.temperature.onyxFormatted)
|
|
.font(Onyx.Font.metric)
|
|
.foregroundStyle(Onyx.Color.textPrimary)
|
|
Text(snapshot.placeName)
|
|
.font(.system(size: 10))
|
|
.foregroundStyle(Onyx.Color.textSecondary)
|
|
.lineLimit(1)
|
|
}
|
|
Spacer(minLength: 0)
|
|
|
|
if let high = snapshot.high, let low = snapshot.low {
|
|
VStack(alignment: .trailing, spacing: 1) {
|
|
Text("↑ \(high.onyxFormatted)")
|
|
Text("↓ \(low.onyxFormatted)")
|
|
}
|
|
.font(Onyx.Font.metricSmall)
|
|
.foregroundStyle(Onyx.Color.textTertiary)
|
|
}
|
|
}
|
|
|
|
if !snapshot.hourly.isEmpty {
|
|
HStack(spacing: 0) {
|
|
ForEach(snapshot.hourly.prefix(5)) { point in
|
|
VStack(spacing: 2) {
|
|
Text(point.date.formatted(.dateTime.hour()))
|
|
.font(.system(size: 8))
|
|
.foregroundStyle(Onyx.Color.textTertiary)
|
|
Image(systemName: point.condition.symbolName(isDaylight: true))
|
|
.font(.system(size: 9))
|
|
.foregroundStyle(Onyx.Color.textSecondary)
|
|
Text(point.temperature.onyxFormatted)
|
|
.font(.system(size: 9))
|
|
.monospacedDigit()
|
|
.foregroundStyle(Onyx.Color.textSecondary)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
}
|
|
}
|
|
|
|
Spacer(minLength: 0)
|
|
|
|
HStack(spacing: 4) {
|
|
if isStale {
|
|
Image(systemName: "exclamationmark.triangle.fill")
|
|
.font(.system(size: 8))
|
|
.foregroundStyle(Onyx.Color.warning)
|
|
}
|
|
Text(stampText)
|
|
.font(.system(size: 8))
|
|
.foregroundStyle(Onyx.Color.textTertiary)
|
|
Spacer(minLength: 0)
|
|
if let attribution = snapshot.attribution {
|
|
// Pflichtangabe, klickbar auf Apples Rechtshinweise.
|
|
Link(attribution.name, destination: attribution.legalPageURL)
|
|
.font(.system(size: 8))
|
|
.foregroundStyle(Onyx.Color.textTertiary)
|
|
}
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
}
|
|
|
|
private var stampText: String {
|
|
let time = snapshot.asOf.formatted(date: .omitted, time: .shortened)
|
|
return String(format: String(localized: "widget.weather.asOf", bundle: .module), time)
|
|
}
|
|
}
|
|
|
|
extension Measurement where UnitType == UnitTemperature {
|
|
/// Ganzzahlig und in der Einheit, die zur Region des Nutzers passt.
|
|
/// Nachkommastellen bei der Außentemperatur täuschen eine Genauigkeit vor,
|
|
/// die die Vorhersage nicht hat.
|
|
var onyxFormatted: String {
|
|
formatted(.measurement(width: .narrow,
|
|
usage: .weather,
|
|
numberFormatStyle: .number.precision(.fractionLength(0))))
|
|
}
|
|
}
|