App-Review-Fix (Guideline 2.1 WeatherKit): - OneShotLocation: deterministisches async resolve() mit 10s-Timeout statt onChange-Lauschen; WetterView lädt bei fehlendem Standort einen Berlin-Fallback → kein ewiges Hängen bei "Hole Standort…", WeatherKit ist immer sichtbar. Offline-Lesen (SwiftData): - CachedRoute/CachedDiaryEntry/CachedImage + CachedAsyncImage: Touren, Tagebuch und Fotos werden cache-first geladen und sind offline verfügbar. - Cache wird bei Logout/401 geleert (RootView), kein Durchschimmern fremder User. Offline-Speichern (Outbox): - PendingRoute/PendingRoutePhoto: Tour inkl. unterwegs hinzugefügter Fotos wird offline lokal gesichert und automatisch hochgeladen (Touren-Tab + App-Start). - Touren-Liste zeigt offline gesicherte Touren mit "wird hochgeladen"-Badge. FinishWalkSheet: - Dismiss-Schutz: Speichern-Dialog lässt sich nicht mehr wegwischen — eine aufgezeichnete Tour geht nicht mehr durch Runterwischen verloren. Wetter: - Ortslabel (Reverse-Geocoding; Fallback "Berlin · Näherung"). - Saubere Offline-Meldung statt rohem networkError. Aufräumen: - Doppeltes "Gassi-Treffen" im Mehr-Tab entfernt. - Veraltete Phase-1/2-Texte neu getextet. - Tote DogsListView gelöscht (Hund-Wechsel läuft über den Heim-Picker).
122 lines
4.6 KiB
Swift
122 lines
4.6 KiB
Swift
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<Void, Never>?
|
|
private let timeoutSeconds: Double = 10
|
|
private var continuation: CheckedContinuation<CLLocationCoordinate2D?, Never>?
|
|
|
|
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<CLLocationCoordinate2D?, Never>) 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
|
|
}
|
|
}
|
|
}
|
|
}
|