Files
onyx/Packages/OnyxKit/Sources/WeatherProvider/WeatherWidget.swift
T
ScarriffleandClaude Opus 5 edb327cabe Kacheln und Fenster sprechen dieselbe Sprache
Alle Kacheln, Popovers und Fenster auf die Designschicht umgestellt: rund
hundert gesetzte Schriftgrößen und 159 gesetzte Abstände und Radien sind jetzt
Token. Übrig bleibt `spacing: 0` — das ist eine Aussage, keine geratene Zahl.

Die Einstellungen bekommen eine Seitenleiste statt sieben Reitern, deren
deutsche Beschriftungen zusammen 880 Punkte brauchten. Panel-Token stehen nicht
mehr in Fenstern: dort gilt Systemsemantik, damit der Hellmodus nicht schwarz
auf schwarz endet.

Neu ist ein Schaufenster, das alle Bauteile nebeneinander zeigt — ohne das
lässt sich eine Änderung an einem Token nur prüfen, indem man die App bedient,
bis jede Kachel einmal zu sehen war.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 21:05:51 +02:00

338 lines
14 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>?
/// Der Takt, in dem nachgesehen wird.
private var timer: Timer?
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) {
startTimerIfNeeded()
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
}
}
}
/// Sieht regelmäßig nach.
///
/// **Ohne das lud das Widget genau einmal je Programmstart.** Der Aufruf
/// hing an `task` der View, und die erscheint bei einem Panel, das immer
/// existiert, ein einziges Mal. Wer Onyx tagelang laufen lässt, sah
/// tagelang dieselbe Temperatur — nachts 35 Grad, weil sie vom Nachmittag
/// des Vortags stammte.
///
/// Der Takt entspricht der Haltbarkeit des Zwischenspeichers. Teurer wird
/// es dadurch nicht: `load` fragt nur nach, wenn der gespeicherte Stand
/// abgelaufen ist. Und der Ort wird dabei neu bestimmt — wer die Stadt
/// wechselt, sieht sonst weiter das Wetter der alten.
private func startTimerIfNeeded() {
guard timer == nil else { return }
let timer = Timer(timeInterval: WeatherCache.maxAge, repeats: true) { [weak self] _ in
Task { @MainActor in self?.load() }
}
RunLoop.main.add(timer, forMode: .common)
self.timer = timer
}
public func stop() {
task?.cancel()
task = nil
timer?.invalidate()
timer = 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):
// Auch ein „fertiger" Stand kann alt sein — dann ist er als
// solcher zu kennzeichnen. Genau das fehlte: drei Tage alte
// 35 Grad standen um elf Uhr nachts da, als wären sie eben
// gemessen worden.
content(snapshot, isStale: WeatherSnapshot.isStale(asOf: snapshot.asOf))
case .unavailable(let lastKnown, _):
if let lastKnown {
content(lastKnown, isStale: true)
} else {
OnyxNotice(symbol: "wifi.slash",
text: String(localized: "widget.weather.unavailable", bundle: .module))
}
case .locationDenied:
OnyxNotice(symbol: "location.slash",
text: String(localized: "widget.weather.locationDenied", bundle: .module))
case .locationUndetermined:
OnyxNotice(symbol: "location.circle",
text: String(localized: "widget.weather.locationUndetermined", bundle: .module))
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 WideWeather: View {
let snapshot: WeatherSnapshot
let isStale: Bool
var body: some View {
// Der Aufbau folgt der Reihenfolge, in der man liest: erst was jetzt
// ist, dann was noch kommt. Vorher stand alles im oberen Drittel und
// die untere Hälfte war leer — bei Schriftgrößen von acht Punkten, die
// man auf Armlänge nicht entziffert.
VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .center, spacing: Onyx.Space.row) {
Image(systemName: snapshot.condition.symbolName(isDaylight: snapshot.isDaylight))
.font(.system(size: Onyx.Symbol.hero))
.symbolRenderingMode(.hierarchical)
.foregroundStyle(Onyx.Color.textPrimary)
VStack(alignment: .leading, spacing: 0) {
Text(snapshot.temperature.onyxFormatted)
.font(Onyx.Font.displayLarge)
.monospacedDigit()
.foregroundStyle(Onyx.Color.textPrimary)
if let high = snapshot.high, let low = snapshot.low {
Text("↑ \(high.onyxFormatted) ↓ \(low.onyxFormatted)")
.font(Onyx.Font.metricSmall)
.monospacedDigit()
.foregroundStyle(Onyx.Color.textTertiary)
}
}
Spacer(minLength: 0)
}
Text(snapshot.placeName)
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textSecondary)
.lineLimit(1)
.padding(.top, Onyx.Space.xxs)
if snapshot.apparentTemperature != snapshot.temperature {
Text("weather.feelsLike \(snapshot.apparentTemperature.onyxFormatted)",
bundle: .module)
.font(Onyx.Font.metricSmall)
.foregroundStyle(Onyx.Color.textTertiary)
}
Spacer(minLength: 6)
if !snapshot.hourly.isEmpty {
OnyxHairline()
// Vier statt fünf Stunden: bei 180 Punkten Breite bleiben so
// gut vierzig je Spalte, und die Zahlen lassen sich in einer
// lesbaren Größe setzen.
HStack(spacing: 0) {
ForEach(snapshot.hourly.prefix(4)) { point in
VStack(spacing: Onyx.Space.xs) {
Text(point.date.formatted(.dateTime.hour()))
.font(Onyx.Font.metricSmall)
.foregroundStyle(Onyx.Color.textTertiary)
Image(systemName: point.condition.symbolName(isDaylight: true))
.font(.system(size: Onyx.Symbol.medium))
.symbolRenderingMode(.hierarchical)
.foregroundStyle(Onyx.Color.textSecondary)
Text(point.temperature.onyxFormatted)
.font(Onyx.Font.caption)
.monospacedDigit()
.foregroundStyle(Onyx.Color.textPrimary)
}
.frame(maxWidth: .infinity)
}
}
.padding(.vertical, Onyx.Space.md)
}
Spacer(minLength: 0)
HStack(spacing: Onyx.Space.xs) {
if isStale {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: Onyx.Symbol.caption))
.foregroundStyle(Onyx.Color.warning)
}
Text(stampText)
.font(Onyx.Font.metricSmall)
.foregroundStyle(Onyx.Color.textTertiary)
Spacer(minLength: 0)
if let attribution = snapshot.attribution {
// Pflichtangabe, klickbar auf Apples Rechtshinweise.
Link(attribution.name, destination: attribution.legalPageURL)
.font(Onyx.Font.metricSmall)
.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)
}
}
public 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.
///
/// Öffentlich, seit die Temperatur auch neben der Notch steht: dieselbe
/// Zahl zweimal verschieden zu runden wäre der Anfang zweier Wahrheiten.
var onyxFormatted: String {
formatted(.measurement(width: .narrow,
usage: .weather,
numberFormatStyle: .number.precision(.fractionLength(0))))
}
}