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