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] = [:] private let url: URL? /// Liest beim Anlegen, was zuletzt bekannt war. /// /// Der Zwischenspeicher liegt auf der Platte und nicht nur im Arbeits- /// speicher: sonst steht beim Start ein Rädchen da, bis der erste Abruf /// durch ist — und wenn der hängt, für immer. Ein Wert von vor einer /// Stunde mit Zeitstempel ist die bessere Auskunft. public init(url: URL? = WeatherCache.standardURL) { self.url = url guard let url, let data = try? Data(contentsOf: url) else { return } // Eine unlesbare Datei ist kein Grund, das Widget aufzugeben. entries = (try? JSONDecoder().decode([String: WeatherSnapshot].self, from: data)) ?? [:] } public static var standardURL: URL? { FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first? .appendingPathComponent("Onyx/weather.json") } public func save() throws { guard let url else { return } try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) try JSONEncoder().encode(entries).write(to: url, options: .atomic) } /// 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" } } } /// Führt einen Aufruf mit Zeitlimit aus. /// /// WeatherKit hat keines. Beim Testen lieferte es das Wetter in 185 ms und /// blieb danach beim Abruf der Namensnennung stehen — das Widget zeigte für /// immer ein Rädchen. Ein Netzaufruf ohne Zeitlimit ist ein Aufruf, der /// hängen darf. func withTimeout(seconds: TimeInterval, operation: @escaping @Sendable () async -> T) async -> T? { await withTaskGroup(of: T?.self) { group in group.addTask { await operation() } group.addTask { try? await Task.sleep(for: .seconds(seconds)) return nil } let first = await group.next() ?? nil group.cancelAll() return first } }