diff --git a/Onyx.xcodeproj/project.pbxproj b/Onyx.xcodeproj/project.pbxproj index 17628cc..03dcfba 100644 --- a/Onyx.xcodeproj/project.pbxproj +++ b/Onyx.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ 378A5A417112CCB31EF3F774 /* OnyxWidgetKit in Frameworks */ = {isa = PBXBuildFile; productRef = E66577A40689DF225BCED184 /* OnyxWidgetKit */; }; 39E29ADC71D4860543AD25EB /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45BD2DF1B775C733C8F75635 /* SettingsView.swift */; }; 4E729C7A6498B4679C8D2C42 /* OnyxCore in Frameworks */ = {isa = PBXBuildFile; productRef = C64A111F449D0F9D36ED46FB /* OnyxCore */; }; + 5BE38CAFCDA4B9CBE787CABA /* WeatherProvider in Frameworks */ = {isa = PBXBuildFile; productRef = D4578EB64F7A3372B49BC50C /* WeatherProvider */; }; 5EA873EC28C7EE21AC85A263 /* PlaceholderWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB5D52C0D22AE1F5E0601AF9 /* PlaceholderWidgets.swift */; }; 68EE5495435A82E20248EBC2 /* OnyxDesign in Frameworks */ = {isa = PBXBuildFile; productRef = 3D680DD645941493A7D3559E /* OnyxDesign */; }; 9C2E419014B7E04630FDF151 /* OnyxApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F89CAC2A7DA89B8707D45D65 /* OnyxApp.swift */; }; @@ -45,6 +46,7 @@ 378A5A417112CCB31EF3F774 /* OnyxWidgetKit in Frameworks */, B6CD48B41866A468AE7A1BC7 /* OnyxMenuBar in Frameworks */, CD501EBAE39807F5D354C5B2 /* CalendarProvider in Frameworks */, + 5BE38CAFCDA4B9CBE787CABA /* WeatherProvider in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -122,6 +124,7 @@ E66577A40689DF225BCED184 /* OnyxWidgetKit */, A7FD7A4864E5ED4B95C211AF /* OnyxMenuBar */, A3E3949D664131D593CBEEDC /* CalendarProvider */, + D4578EB64F7A3372B49BC50C /* WeatherProvider */, ); productName = Onyx; productReference = 0804288DC4A8146DD9F3FC3E /* Onyx.app */; @@ -414,6 +417,10 @@ isa = XCSwiftPackageProductDependency; productName = OnyxCore; }; + D4578EB64F7A3372B49BC50C /* WeatherProvider */ = { + isa = XCSwiftPackageProductDependency; + productName = WeatherProvider; + }; E66577A40689DF225BCED184 /* OnyxWidgetKit */ = { isa = XCSwiftPackageProductDependency; productName = OnyxWidgetKit; diff --git a/Onyx/Onyx.entitlements b/Onyx/Onyx.entitlements index 6523950..9a00abb 100644 --- a/Onyx/Onyx.entitlements +++ b/Onyx/Onyx.entitlements @@ -57,5 +57,27 @@ nichts; die Berechtigung heißt nur so. --> com.apple.security.device.audio-input + + diff --git a/Onyx/OnyxApp.swift b/Onyx/OnyxApp.swift index f3f42d4..eb9e4ca 100644 --- a/Onyx/OnyxApp.swift +++ b/Onyx/OnyxApp.swift @@ -6,6 +6,7 @@ import OnyxNotch import OnyxMenuBar import OnyxWidgetKit import CalendarProvider +import WeatherProvider @main struct OnyxApp: App { @@ -30,6 +31,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private var menuBar: MenuBarController? private var onyxItem: NSStatusItem? private var calendarModel: CalendarModel? + private var weatherModel: WeatherModel? private let settingsWindow = SettingsWindowController() func applicationDidFinishLaunching(_ notification: Notification) { @@ -119,6 +121,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { self.calendarModel = calendarModel WidgetRegistry.shared.register(CalendarWidget(model: calendarModel)) + let weatherModel = WeatherModel() + self.weatherModel = weatherModel + WidgetRegistry.shared.register(WeatherWidget(model: weatherModel)) + PlaceholderWidgets.registerAll() } diff --git a/Onyx/PlaceholderWidgets.swift b/Onyx/PlaceholderWidgets.swift index b3fa9f7..062670b 100644 --- a/Onyx/PlaceholderWidgets.swift +++ b/Onyx/PlaceholderWidgets.swift @@ -44,8 +44,6 @@ enum PlaceholderWidgets { /// damit gespeicherte Layouts weitergelten. static func registerAll() { let widgets: [PlaceholderWidget] = [ - .init(id: "weather", displayName: "Wetter", symbolName: "cloud.sun", - supportedSizes: [.small, .medium], phase: "Phase 3"), .init(id: "media", displayName: "Medien", symbolName: "play.circle", supportedSizes: [.medium, .wide], phase: "Phase 4"), .init(id: "cpu", displayName: "CPU", symbolName: "cpu", diff --git a/Packages/OnyxKit/Package.swift b/Packages/OnyxKit/Package.swift index 47f06a5..56f02de 100644 --- a/Packages/OnyxKit/Package.swift +++ b/Packages/OnyxKit/Package.swift @@ -14,6 +14,7 @@ let package = Package( .library(name: "OnyxWidgetKit", targets: ["OnyxWidgetKit"]), .library(name: "OnyxMenuBar", targets: ["OnyxMenuBar"]), .library(name: "CalendarProvider", targets: ["CalendarProvider"]), + .library(name: "WeatherProvider", targets: ["WeatherProvider"]), ], targets: [ // Der String-Katalog muss ausdrücklich als Ressource stehen — sonst gibt @@ -36,5 +37,9 @@ let package = Package( .target(name: "CalendarProvider", dependencies: ["OnyxDesign", "OnyxWidgetKit"], resources: [.process("Localizable.xcstrings")]), .testTarget(name: "CalendarProviderTests", dependencies: ["CalendarProvider"]), + + .target(name: "WeatherProvider", dependencies: ["OnyxDesign", "OnyxWidgetKit"], + resources: [.process("Localizable.xcstrings")]), + .testTarget(name: "WeatherProviderTests", dependencies: ["WeatherProvider"]), ] ) diff --git a/Packages/OnyxKit/Sources/WeatherProvider/Localizable.xcstrings b/Packages/OnyxKit/Sources/WeatherProvider/Localizable.xcstrings new file mode 100644 index 0000000..33651dc --- /dev/null +++ b/Packages/OnyxKit/Sources/WeatherProvider/Localizable.xcstrings @@ -0,0 +1,31 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "widget.weather.name" : { + "localizations" : { + "de" : { "stringUnit" : { "state" : "translated", "value" : "Wetter" } }, + "en" : { "stringUnit" : { "state" : "translated", "value" : "Weather" } } + } + }, + "widget.weather.asOf" : { + "comment" : "%@ ist die Uhrzeit des Messwerts", + "localizations" : { + "de" : { "stringUnit" : { "state" : "translated", "value" : "Stand %@" } }, + "en" : { "stringUnit" : { "state" : "translated", "value" : "As of %@" } } + } + }, + "widget.weather.unavailable" : { + "localizations" : { + "de" : { "stringUnit" : { "state" : "translated", "value" : "Wetter nicht abrufbar" } }, + "en" : { "stringUnit" : { "state" : "translated", "value" : "Weather unavailable" } } + } + }, + "widget.weather.locationDenied" : { + "localizations" : { + "de" : { "stringUnit" : { "state" : "translated", "value" : "Kein Standortzugriff.\nOrt in den Einstellungen wählen." } }, + "en" : { "stringUnit" : { "state" : "translated", "value" : "No location access.\nPick a place in settings." } } + } + } + }, + "version" : "1.0" +} diff --git a/Packages/OnyxKit/Sources/WeatherProvider/WeatherCache.swift b/Packages/OnyxKit/Sources/WeatherProvider/WeatherCache.swift new file mode 100644 index 0000000..63428f9 --- /dev/null +++ b/Packages/OnyxKit/Sources/WeatherProvider/WeatherCache.swift @@ -0,0 +1,79 @@ +import Foundation + +/// Hält den zuletzt geladenen Wetterstand je Ort. +/// +/// WeatherKit rechnet nach Abrufen ab. Ein Widget, das bei jedem Öffnen des +/// Panels neu lädt, verbrennt das Kontingent für Daten, die sich stündlich +/// ändern — der Zwischenspeicher ist deshalb Teil der Funktion und kein +/// Feinschliff. +public struct WeatherCache: Sendable { + + /// Wie lange ein Stand als aktuell gilt. + public static let maxAge: TimeInterval = 15 * 60 + + private var entries: [String: WeatherSnapshot] = [:] + + public init() {} + + /// Der Stand, wenn er noch frisch genug ist. + public func valid(for place: WeatherPlace, now: Date = Date()) -> WeatherSnapshot? { + guard let entry = entries[Self.key(place)] else { return nil } + return now.timeIntervalSince(entry.asOf) <= Self.maxAge ? entry : nil + } + + /// Der letzte bekannte Stand, unabhängig vom Alter. + /// + /// Bei einem Netzfehler ist ein alter Messwert mit sichtbarem Zeitstempel + /// deutlich besser als ein leeres Feld — man sieht, dass etwas da ist und + /// wie alt es ist. + public func lastKnown(for place: WeatherPlace) -> WeatherSnapshot? { + entries[Self.key(place)] + } + + public mutating func store(_ snapshot: WeatherSnapshot, for place: WeatherPlace) { + entries[Self.key(place)] = snapshot + } + + /// Der Schlüssel muss den Ort mitführen, sonst zeigt das Widget nach einem + /// Ortswechsel weiter das alte Wetter — und zwar überzeugend, weil Zahlen + /// dastehen. + private static func key(_ place: WeatherPlace) -> String { + switch place { + case .current: + "current" + case .fixed(_, let latitude, let longitude): + // Auf vier Nachkommastellen gerundet: das sind gut zehn Meter, + // feiner unterscheidet sich das Wetter ohnehin nicht. + String(format: "%.4f,%.4f", latitude, longitude) + } + } +} + +public extension WeatherCondition { + /// Das SF-Symbol zur Lage. + /// + /// Nur dort zwischen Tag und Nacht unterscheiden, wo es ein etabliertes + /// Symbolpaar gibt. Eine erzwungene Unterscheidung erzeugt sonst Symbole, + /// die niemand wiedererkennt. + func symbolName(isDaylight: Bool) -> String { + switch self { + case .clear: isDaylight ? "sun.max.fill" : "moon.stars.fill" + case .mostlyClear: isDaylight ? "sun.min.fill" : "moon.fill" + case .partlyCloudy: isDaylight ? "cloud.sun.fill" : "cloud.moon.fill" + case .cloudy: "cloud.fill" + case .fog: "cloud.fog.fill" + case .drizzle: "cloud.drizzle.fill" + case .rain: "cloud.rain.fill" + case .heavyRain: "cloud.heavyrain.fill" + case .sleet: "cloud.sleet.fill" + case .snow: "cloud.snow.fill" + case .heavySnow: "snowflake" + case .hail: "cloud.hail.fill" + case .thunderstorm: "cloud.bolt.rain.fill" + case .windy: "wind" + case .hot: "thermometer.sun.fill" + case .frigid: "thermometer.snowflake" + case .unknown: "questionmark.circle" + } + } +} diff --git a/Packages/OnyxKit/Sources/WeatherProvider/WeatherKitSource.swift b/Packages/OnyxKit/Sources/WeatherProvider/WeatherKitSource.swift new file mode 100644 index 0000000..29b818e --- /dev/null +++ b/Packages/OnyxKit/Sources/WeatherProvider/WeatherKitSource.swift @@ -0,0 +1,189 @@ +import Foundation +import OSLog +import CoreLocation +import WeatherKit + +private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Weather") + +/// Liefert den aktuellen Standort, oder sagt warum nicht. +/// +/// Eigene Klasse statt eines Aufrufs mittendrin: `CLLocationManager` meldet +/// asynchron über einen Delegaten, und die Berechtigung kann sich jederzeit +/// ändern. Beides gehört an eine Stelle. +@MainActor +final class LocationProvider: NSObject, CLLocationManagerDelegate { + + private let manager = CLLocationManager() + private var pending: [CheckedContinuation] = [] + + override init() { + super.init() + manager.delegate = self + manager.desiredAccuracy = kCLLocationAccuracyKilometer + } + + var authorization: CLAuthorizationStatus { manager.authorizationStatus } + + var isDenied: Bool { + authorization == .denied || authorization == .restricted + } + + func requestAuthorization() { + guard authorization == .notDetermined else { return } + manager.requestWhenInUseAuthorization() + } + + /// Eine einzelne Ortung. Liefert `nil`, wenn keine Berechtigung vorliegt + /// oder nichts hereinkommt. + func currentLocation() async -> CLLocation? { + guard !isDenied else { return nil } + if authorization == .notDetermined { + requestAuthorization() + // Auf die Entscheidung des Nutzers warten hieße, hier zu blockieren. + // Beim nächsten Durchlauf steht sie fest. + return nil + } + if let cached = manager.location, cached.timestamp.timeIntervalSinceNow > -900 { + return cached + } + return await withCheckedContinuation { continuation in + pending.append(continuation) + manager.requestLocation() + } + } + + private func resume(with location: CLLocation?) { + let waiting = pending + pending.removeAll() + waiting.forEach { $0.resume(returning: location) } + } + + nonisolated func locationManager(_ manager: CLLocationManager, + didUpdateLocations locations: [CLLocation]) { + Task { @MainActor in self.resume(with: locations.last) } + } + + nonisolated func locationManager(_ manager: CLLocationManager, + didFailWithError error: Error) { + Task { @MainActor in + log.error("Ortung fehlgeschlagen: \(error.localizedDescription, privacy: .public)") + self.resume(with: nil) + } + } +} + +/// Wetter aus Apples WeatherKit. +@MainActor +public final class WeatherKitSource { + + private let service = WeatherService.shared + private let location = LocationProvider() + private let geocoder = CLGeocoder() + + public init() {} + + public var locationIsDenied: Bool { location.isDenied } + public func requestLocationAuthorization() { location.requestAuthorization() } + + public func load(_ place: WeatherPlace) async -> WeatherState { + let coordinate: CLLocation + let placeName: String + + switch place { + case .current: + guard let here = await location.currentLocation() else { + return location.isDenied + ? .locationDenied + : .unavailable(lastKnown: nil, reason: "Standort nicht verfügbar") + } + coordinate = here + placeName = await name(for: here) + + case .fixed(let name, let latitude, let longitude): + coordinate = CLLocation(latitude: latitude, longitude: longitude) + placeName = name + } + + do { + let weather = try await service.weather(for: coordinate) + return .ready(Self.snapshot(from: weather, + placeName: placeName, + attribution: try? await attribution())) + } catch { + log.error("WeatherKit: \(error.localizedDescription, privacy: .public)") + return .unavailable(lastKnown: nil, reason: error.localizedDescription) + } + } + + /// Apples Namensnennung. Keine Kür — sie ist Bedingung der Nutzung. + private func attribution() async throws -> WeatherAttribution { + let legal = try await service.attribution + return WeatherAttribution(name: "Apple Weather", + legalPageURL: legal.legalPageURL, + logoURL: legal.combinedMarkDarkURL) + } + + private func name(for location: CLLocation) async -> String { + guard let placemark = try? await geocoder.reverseGeocodeLocation(location).first else { + return "Aktueller Ort" + } + return placemark.locality ?? placemark.subAdministrativeArea + ?? placemark.administrativeArea ?? "Aktueller Ort" + } + + // MARK: - Umwandlung + + private static func snapshot(from weather: Weather, + placeName: String, + attribution: WeatherAttribution?) -> WeatherSnapshot { + let current = weather.currentWeather + let today = weather.dailyForecast.first + + return WeatherSnapshot( + placeName: placeName, + temperature: current.temperature, + apparentTemperature: current.apparentTemperature, + condition: map(current.condition), + isDaylight: current.isDaylight, + humidity: current.humidity, + windSpeed: current.wind.speed, + high: today?.highTemperature, + low: today?.lowTemperature, + hourly: weather.hourlyForecast.forecast + .filter { $0.date >= Date() } + .prefix(12) + .map { + HourlyPoint(date: $0.date, + temperature: $0.temperature, + condition: map($0.condition), + precipitationChance: $0.precipitationChance) + }, + asOf: current.date, + attribution: attribution) + } + + /// WeatherKit kennt deutlich mehr Lagen als hier unterschieden werden. + /// Feiner aufzulösen bringt nichts: das Widget hat ein Symbol und ein Wort + /// Platz, und „mäßiger Sprühregen" liest dort niemand. + private static func map(_ condition: WeatherKit.WeatherCondition) -> WeatherCondition { + switch condition { + case .clear: .clear + case .mostlyClear: .mostlyClear + case .partlyCloudy, .mostlyCloudy: .partlyCloudy + case .cloudy: .cloudy + case .foggy, .haze, .smoky: .fog + case .drizzle, .freezingDrizzle: .drizzle + case .rain, .sunShowers, .freezingRain: .rain + case .heavyRain: .heavyRain + case .sleet, .wintryMix, .hail: .sleet + case .snow, .flurries, .sunFlurries, .blowingSnow: .snow + case .heavySnow, .blizzard: .heavySnow + case .thunderstorms, .isolatedThunderstorms, .scatteredThunderstorms, + .strongStorms, .tropicalStorm, .hurricane: .thunderstorm + case .windy, .breezy, .blowingDust: .windy + case .hot: .hot + case .frigid: .frigid + @unknown default: .unknown + } + } +} diff --git a/Packages/OnyxKit/Sources/WeatherProvider/WeatherModels.swift b/Packages/OnyxKit/Sources/WeatherProvider/WeatherModels.swift new file mode 100644 index 0000000..533f9f7 --- /dev/null +++ b/Packages/OnyxKit/Sources/WeatherProvider/WeatherModels.swift @@ -0,0 +1,125 @@ +import Foundation + +/// Wetterlage, unabhängig von der Quelle. +/// +/// Eine eigene Aufzählung statt WeatherKits Typ: die Widget-Ebene soll nicht +/// gegen ein Framework gebunden sein, das eine kostenpflichtige Mitgliedschaft +/// und eine Entitlement voraussetzt. Fällt WeatherKit einmal weg, wird nur die +/// Abbildung ausgetauscht. +public enum WeatherCondition: String, Equatable, Sendable, CaseIterable { + case clear + case mostlyClear + case partlyCloudy + case cloudy + case fog + case drizzle + case rain + case heavyRain + case sleet + case snow + case heavySnow + case hail + case thunderstorm + case windy + case hot + case frigid + case unknown +} + +/// Der Ort, für den Wetter angezeigt wird. +public enum WeatherPlace: Equatable, Sendable, Codable { + /// Aktueller Standort über CoreLocation. + case current + /// Fester Ort, in den Widget-Einstellungen gewählt. + case fixed(name: String, latitude: Double, longitude: Double) + + public var isCurrent: Bool { self == .current } +} + +public struct HourlyPoint: Equatable, Sendable, Identifiable { + public let date: Date + public let temperature: Measurement + public let condition: WeatherCondition + public let precipitationChance: Double + + public var id: Date { date } + + public init(date: Date, temperature: Measurement, + condition: WeatherCondition, precipitationChance: Double) { + self.date = date + self.temperature = temperature + self.condition = condition + self.precipitationChance = precipitationChance + } +} + +/// Was Apple bei Nutzung von WeatherKit sichtbar verlangt. +/// +/// Das ist keine Kür: die Nennung samt Link auf die Rechtshinweise ist +/// Bedingung der Nutzung. Deshalb steht sie im Datenmodell und nicht in einer +/// Notiz, die man beim Bauen der Oberfläche übersieht. +public struct WeatherAttribution: Equatable, Sendable { + public let name: String + public let legalPageURL: URL + public let logoURL: URL? + + public init(name: String, legalPageURL: URL, logoURL: URL?) { + self.name = name + self.legalPageURL = legalPageURL + self.logoURL = logoURL + } +} + +public struct WeatherSnapshot: Equatable, Sendable { + public let placeName: String + public let temperature: Measurement + public let apparentTemperature: Measurement + public let condition: WeatherCondition + public let isDaylight: Bool + public let humidity: Double + public let windSpeed: Measurement + public let high: Measurement? + public let low: Measurement? + public let hourly: [HourlyPoint] + public let asOf: Date + public let attribution: WeatherAttribution? + + public init(placeName: String, + temperature: Measurement, + apparentTemperature: Measurement, + condition: WeatherCondition, + isDaylight: Bool, + humidity: Double, + windSpeed: Measurement, + high: Measurement?, + low: Measurement?, + hourly: [HourlyPoint], + asOf: Date, + attribution: WeatherAttribution?) { + self.placeName = placeName + self.temperature = temperature + self.apparentTemperature = apparentTemperature + self.condition = condition + self.isDaylight = isDaylight + self.humidity = humidity + self.windSpeed = windSpeed + self.high = high + self.low = low + self.hourly = hourly + self.asOf = asOf + self.attribution = attribution + } +} + +/// Wie beim Kalender: jeder Fehlerfall bekommt eine eigene Antwort in der +/// Oberfläche. „Keine Daten" ist keine. +public enum WeatherState: Equatable, Sendable { + case ready(WeatherSnapshot) + /// Noch nie geladen. + case idle + case loading + /// Ortungsberechtigung fehlt — betrifft nur `.current`. + case locationDenied + /// Kein Netz oder Dienst nicht erreichbar. Der letzte Stand bleibt sichtbar. + case unavailable(lastKnown: WeatherSnapshot?, reason: String) +} diff --git a/Packages/OnyxKit/Sources/WeatherProvider/WeatherWidget.swift b/Packages/OnyxKit/Sources/WeatherProvider/WeatherWidget.swift new file mode 100644 index 0000000..2e11d36 --- /dev/null +++ b/Packages/OnyxKit/Sources/WeatherProvider/WeatherWidget.swift @@ -0,0 +1,264 @@ +import SwiftUI +import AppKit +import OnyxDesign +import OnyxWidgetKit + +/// Hält den Wetterstand für das Widget. +@MainActor +@Observable +public final class WeatherModel { + + public private(set) var state: WeatherState = .idle + 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 + } + + public var locationIsDenied: Bool { source.locationIsDenied } + 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 .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)))) + } +} diff --git a/Packages/OnyxKit/Tests/WeatherProviderTests/WeatherCacheTests.swift b/Packages/OnyxKit/Tests/WeatherProviderTests/WeatherCacheTests.swift new file mode 100644 index 0000000..0dbf15f --- /dev/null +++ b/Packages/OnyxKit/Tests/WeatherProviderTests/WeatherCacheTests.swift @@ -0,0 +1,138 @@ +import Testing +import Foundation +@testable import WeatherProvider + +// WeatherKit rechnet nach Abrufen ab (500.000 im Monat inklusive). Ein Widget, +// das bei jedem Öffnen des Panels neu lädt, verbrennt das Kontingent für Daten, +// die sich stündlich ändern. Der Zwischenspeicher ist deshalb kein Feinschliff, +// sondern Teil der Funktion. + +@Suite("Wetter-Zwischenspeicher") +struct WeatherCacheTests { + + private let place = WeatherPlace.fixed(name: "Lemgo", latitude: 52.02, longitude: 8.9) + private let other = WeatherPlace.fixed(name: "Berlin", latitude: 52.52, longitude: 13.40) + + private func snapshot(at date: Date) -> WeatherSnapshot { + WeatherSnapshot(placeName: "Lemgo", + temperature: .init(value: 18, unit: .celsius), + apparentTemperature: .init(value: 17, unit: .celsius), + condition: .partlyCloudy, isDaylight: true, humidity: 0.6, + windSpeed: .init(value: 10, unit: .kilometersPerHour), + high: nil, low: nil, hourly: [], asOf: date, attribution: nil) + } + + @Test("Frischer Eintrag wird wiederverwendet") + func freshEntryIsReused() { + let now = Date() + var cache = WeatherCache() + cache.store(snapshot(at: now), for: place) + + #expect(cache.valid(for: place, now: now.addingTimeInterval(60)) != nil) + } + + @Test("Nach Ablauf der Frist wird neu geladen") + func staleEntryIsDiscarded() { + let now = Date() + var cache = WeatherCache() + cache.store(snapshot(at: now), for: place) + + let later = now.addingTimeInterval(WeatherCache.maxAge + 1) + #expect(cache.valid(for: place, now: later) == nil) + } + + @Test("Genau auf der Frist gilt der Eintrag noch") + func exactlyAtMaxAgeIsStillValid() { + let now = Date() + var cache = WeatherCache() + cache.store(snapshot(at: now), for: place) + + #expect(cache.valid(for: place, now: now.addingTimeInterval(WeatherCache.maxAge)) != nil) + } + + @Test("Ein anderer Ort benutzt nicht den Eintrag des ersten") + func differentPlaceMisses() { + // Sonst zeigt das Widget nach dem Umstellen des Ortes weiter das alte + // Wetter — und zwar überzeugend, weil Zahlen dastehen. + let now = Date() + var cache = WeatherCache() + cache.store(snapshot(at: now), for: place) + + #expect(cache.valid(for: other, now: now) == nil) + } + + @Test("Der aktuelle Standort ist ein eigener Eintrag") + func currentLocationIsItsOwnEntry() { + let now = Date() + var cache = WeatherCache() + cache.store(snapshot(at: now), for: .current) + + #expect(cache.valid(for: .current, now: now) != nil) + #expect(cache.valid(for: place, now: now) == nil) + } + + @Test("Leerer Zwischenspeicher liefert nichts") + func emptyCacheMisses() { + #expect(WeatherCache().valid(for: place, now: Date()) == nil) + } + + @Test("Ein abgelaufener Eintrag bleibt als letzter bekannter Stand erhalten") + func staleEntryRemainsAsLastKnown() { + // Bei einem Netzfehler ist ein alter Messwert mit sichtbarem Zeitstempel + // deutlich besser als ein leeres Feld. + let now = Date() + var cache = WeatherCache() + cache.store(snapshot(at: now), for: place) + + let later = now.addingTimeInterval(WeatherCache.maxAge * 10) + #expect(cache.valid(for: place, now: later) == nil) + #expect(cache.lastKnown(for: place) != nil) + } + + @Test("Ein neuer Eintrag ersetzt den alten desselben Ortes") + func storingReplaces() { + let now = Date() + var cache = WeatherCache() + cache.store(snapshot(at: now.addingTimeInterval(-1000)), for: place) + cache.store(snapshot(at: now), for: place) + + #expect(cache.lastKnown(for: place)?.asOf == now) + } + + @Test("Die Frist entspricht dem Plan: 15 Minuten") + func maxAgeMatchesPlan() { + #expect(WeatherCache.maxAge == 15 * 60) + } +} + +@Suite("Wettersymbole") +struct WeatherSymbolTests { + + @Test("Klarer Himmel zeigt tagsüber die Sonne, nachts den Mond") + func clearDependsOnDaylight() { + #expect(WeatherCondition.clear.symbolName(isDaylight: true) == "sun.max.fill") + #expect(WeatherCondition.clear.symbolName(isDaylight: false) == "moon.stars.fill") + } + + @Test("Teilweise bewölkt unterscheidet ebenfalls Tag und Nacht") + func partlyCloudyDependsOnDaylight() { + #expect(WeatherCondition.partlyCloudy.symbolName(isDaylight: true) != + WeatherCondition.partlyCloudy.symbolName(isDaylight: false)) + } + + @Test("Regen sieht nachts aus wie tagsüber") + func rainIgnoresDaylight() { + // Nicht jede Lage hat eine sinnvolle Nachtvariante. Eine erzwungene + // Unterscheidung erzeugt nur Symbole, die niemand wiedererkennt. + #expect(WeatherCondition.rain.symbolName(isDaylight: true) == + WeatherCondition.rain.symbolName(isDaylight: false)) + } + + @Test("Jede Lage liefert ein Symbol — auch die unbekannte") + func everyConditionHasASymbol() { + for condition in WeatherCondition.allCases { + #expect(!condition.symbolName(isDaylight: true).isEmpty) + #expect(!condition.symbolName(isDaylight: false).isEmpty) + } + } +} diff --git a/project.yml b/project.yml index 3bdcd26..6cc47fa 100644 --- a/project.yml +++ b/project.yml @@ -47,6 +47,8 @@ targets: product: OnyxMenuBar - package: OnyxKit product: CalendarProvider + - package: OnyxKit + product: WeatherProvider settings: base: PRODUCT_BUNDLE_IDENTIFIER: com.scarriffleservices.onyx