banyaro-ios/BanYaroGo/Support/OneShotLocation.swift
rene a2646a18ef 1.1: Offline-Cache + Outbox für Touren/Tagebuch, WeatherKit-Fix, Aufräumen
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).
2026-06-02 19:37:30 +02:00

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