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).
This commit is contained in:
parent
9e51f3910e
commit
a2646a18ef
16 changed files with 769 additions and 199 deletions
|
|
@ -3,11 +3,26 @@ import Observation
|
|||
import CoreLocation
|
||||
|
||||
/// Asks CLLocationManager for the user's current location once. Used by
|
||||
/// Wetter and Giftköder which need a position without the full tracking setup.
|
||||
/// 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?
|
||||
|
|
@ -19,20 +34,61 @@ final class OneShotLocation: NSObject, CLLocationManagerDelegate {
|
|||
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
|
||||
switch manager.authorizationStatus {
|
||||
case .notDetermined:
|
||||
manager.requestWhenInUseAuthorization()
|
||||
case .denied, .restricted:
|
||||
error = "Standortzugriff verweigert."
|
||||
isResolving = false
|
||||
case .authorizedWhenInUse, .authorizedAlways:
|
||||
manager.requestLocation()
|
||||
@unknown default:
|
||||
error = "Unbekannter Standort-Status."
|
||||
isResolving = false
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -40,31 +96,24 @@ final class OneShotLocation: NSObject, CLLocationManagerDelegate {
|
|||
_ manager: CLLocationManager,
|
||||
didUpdateLocations locations: [CLLocation]
|
||||
) {
|
||||
guard let loc = locations.first else { return }
|
||||
let c = loc.coordinate
|
||||
Task { @MainActor in
|
||||
self.coordinate = c
|
||||
self.isResolving = false
|
||||
}
|
||||
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.error = msg
|
||||
self.isResolving = false
|
||||
}
|
||||
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.error = "Standortzugriff verweigert."
|
||||
self.isResolving = false
|
||||
self.complete(coord: nil, errorText: "Standortzugriff verweigert.")
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue