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 }; load(force: true) } } private let source: WeatherKitSource private var cache = WeatherCache() private var task: Task? public init(source: WeatherKitSource = WeatherKitSource(), place: WeatherPlace = .current) { self.source = source self.place = place 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) 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) default: state = result } } } public func stop() { task?.cancel() task = nil } } // 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" public let supportedSizes: [WidgetSize] = [.small, .medium] private let model: WeatherModel public init(model: WeatherModel) { self.model = model } public func makeView(size: WidgetSize) -> AnyView { AnyView(WeatherWidgetView(model: model, size: size)) } } private struct WeatherWidgetView: View { let model: WeatherModel let size: WidgetSize 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 { if size == .small { CompactWeather(snapshot: snapshot, isStale: isStale) } else { 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 CompactWeather: View { let snapshot: WeatherSnapshot let isStale: Bool var body: some View { VStack(alignment: .leading, spacing: 2) { Image(systemName: snapshot.condition.symbolName(isDaylight: snapshot.isDaylight)) .font(.system(size: 18)) .foregroundStyle(Onyx.Color.textPrimary) .symbolRenderingMode(.hierarchical) Spacer(minLength: 0) Text(snapshot.temperature.onyxFormatted) .font(Onyx.Font.metric) .foregroundStyle(Onyx.Color.textPrimary) Text(snapshot.placeName) .font(.system(size: 9)) .foregroundStyle(Onyx.Color.textTertiary) .lineLimit(1) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .opacity(isStale ? 0.55 : 1) .help(helpText) } private var helpText: Text { // Apples Namensnennung ist Bedingung der Nutzung. In der 1×1-Kachel ist // dafür kein Platz — sie steht hier und zusätzlich in den Einstellungen. Text(verbatim: [snapshot.placeName, snapshot.attribution.map { "Wetterdaten: \($0.name)" }] .compactMap { $0 }.joined(separator: " · ")) } } 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)))) } }