import Foundation import Observation import CoreLocation /// Asks CLLocationManager for the user's current location once. Used by /// Wetter und Giftköder, die eine Position ohne das volle Tracking-Setup /// brauchen. /// /// Zwei Wege: /// - `resolve()` (async): liefert die Koordinate **oder nil** nach Fix, Fehler, /// verweigerter Berechtigung oder **Timeout**. Deterministisch — keine /// Abhängigkeit von SwiftUI-`onChange`-Zustandswechseln. /// - `request()` (observable): füllt `coordinate`/`error`/`isResolving` für /// Views, die den Zustand direkt beobachten. /// /// Der Timeout verhindert, dass ein Screen ewig bei „Hole Standort…" hängt, /// wenn CoreLocation (z. B. auf einem Review-iPad ohne Position) weder Fix noch /// Fehler liefert. @Observable @MainActor final class OneShotLocation: NSObject, CLLocationManagerDelegate { private let manager = CLLocationManager() private var timeoutTask: Task? private let timeoutSeconds: Double = 10 private var continuation: CheckedContinuation? var coordinate: CLLocationCoordinate2D? var error: String? var isResolving: Bool = false override init() { super.init() manager.delegate = self manager.desiredAccuracy = kCLLocationAccuracyHundredMeters } /// Beobachtbarer Weg (Giftköder): startet die Auflösung, Ergebnis landet in /// `coordinate`/`error`. func request() { isResolving = true Task { _ = await resolve() } } /// Async-Weg (Wetter): wartet auf Fix/Fehler/Timeout und liefert die /// Koordinate oder nil. Aufrufer kann danach z. B. einen Fallback nutzen. func resolve() async -> CLLocationCoordinate2D? { if let coordinate { return coordinate } // Schon eine Auflösung im Gange? Nicht doppelt starten (Continuation-Leak). if continuation != nil { return nil } error = nil isResolving = true return await withCheckedContinuation { (cont: CheckedContinuation) in self.continuation = cont self.startTimeout() switch self.manager.authorizationStatus { case .notDetermined: self.manager.requestWhenInUseAuthorization() case .denied, .restricted: self.complete(coord: nil, errorText: "Standortzugriff verweigert.") case .authorizedWhenInUse, .authorizedAlways: self.manager.requestLocation() @unknown default: self.complete(coord: nil, errorText: "Unbekannter Standort-Status.") } } } private func startTimeout() { timeoutTask?.cancel() let seconds = timeoutSeconds timeoutTask = Task { [weak self] in try? await Task.sleep(for: .seconds(seconds)) guard let self, !Task.isCancelled else { return } if self.continuation != nil { self.complete(coord: nil, errorText: "Standort nicht ermittelbar (Zeitüberschreitung).") } } } /// Einziger Endpunkt für jeden Ausgang. Resümiert die Continuation genau /// einmal und aktualisiert den beobachtbaren Zustand. private func complete(coord: CLLocationCoordinate2D?, errorText: String?) { timeoutTask?.cancel() timeoutTask = nil if let coord { coordinate = coord } if coord == nil, let errorText { error = errorText } isResolving = false if let cont = continuation { continuation = nil cont.resume(returning: coord ?? coordinate) } } nonisolated func locationManager( _ manager: CLLocationManager, didUpdateLocations locations: [CLLocation] ) { guard let c = locations.first?.coordinate else { return } Task { @MainActor in self.complete(coord: c, errorText: nil) } } nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError err: Error) { let msg = err.localizedDescription Task { @MainActor in self.complete(coord: nil, errorText: msg) } } nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { let status = manager.authorizationStatus Task { @MainActor in guard self.continuation != nil else { return } switch status { case .authorizedWhenInUse, .authorizedAlways: manager.requestLocation() case .denied, .restricted: self.complete(coord: nil, errorText: "Standortzugriff verweigert.") default: break } } } }