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>
338 lines
14 KiB
Swift
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))))
|
|
}
|
|
}
|