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? 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 { // 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: 10) { Image(systemName: snapshot.condition.symbolName(isDaylight: snapshot.isDaylight)) .font(.system(size: 34)) .symbolRenderingMode(.hierarchical) .foregroundStyle(Onyx.Color.textPrimary) VStack(alignment: .leading, spacing: 0) { Text(snapshot.temperature.onyxFormatted) .font(.system(size: 30, weight: .medium)) .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, 2) 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 { Divider().overlay(Onyx.Color.hairline) // 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: 3) { Text(point.date.formatted(.dateTime.hour())) .font(Onyx.Font.metricSmall) .foregroundStyle(Onyx.Color.textTertiary) Image(systemName: point.condition.symbolName(isDaylight: true)) .font(.system(size: 13)) .symbolRenderingMode(.hierarchical) .foregroundStyle(Onyx.Color.textSecondary) Text(point.temperature.onyxFormatted) .font(Onyx.Font.caption) .monospacedDigit() .foregroundStyle(Onyx.Color.textPrimary) } .frame(maxWidth: .infinity) } } .padding(.vertical, 8) } Spacer(minLength: 0) HStack(spacing: 4) { if isStale { Image(systemName: "exclamationmark.triangle.fill") .font(.system(size: 9)) .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) } } 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)))) } }