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 } } }