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:
rene 2026-06-02 19:37:30 +02:00
parent 9e51f3910e
commit a2646a18ef
16 changed files with 769 additions and 199 deletions

View file

@ -1,6 +1,9 @@
import SwiftUI
import SwiftData
struct RoutesListView: View {
@Environment(\.modelContext) private var ctx
@Query(sort: \PendingRoute.createdAt, order: .reverse) private var pending: [PendingRoute]
@State private var routes: [RouteListItem] = []
@State private var isLoading = false
@State private var errorMessage: String?
@ -15,26 +18,39 @@ struct RoutesListView: View {
@ViewBuilder
private var content: some View {
if isLoading && routes.isEmpty {
if isLoading && routes.isEmpty && pending.isEmpty {
ProgressView()
} else if let error = errorMessage, routes.isEmpty {
} else if let error = errorMessage, routes.isEmpty && pending.isEmpty {
ContentUnavailableView(
"Konnte Touren nicht laden",
systemImage: "wifi.slash",
description: Text(error)
)
} else if routes.isEmpty {
} else if routes.isEmpty && pending.isEmpty {
ContentUnavailableView(
"Keine Touren",
systemImage: "map",
description: Text("Lege deine erste Gassi-Tour in der PWA an — oder warte auf Phase 2.")
description: Text("Zeichne deine erste Gassi-Tour über den Tab „Aufnehmen“ auf — oder importiere einen GPX-Track.")
)
} else {
List(routes) { route in
NavigationLink {
RouteDetailView(routeId: route.id, fallbackName: route.name)
} label: {
RouteRowView(route: route)
List {
if !pending.isEmpty {
Section {
ForEach(pending) { p in
PendingRouteRow(route: p)
}
} header: {
Label("Offline wird hochgeladen", systemImage: "icloud.and.arrow.up")
}
}
Section {
ForEach(routes) { route in
NavigationLink {
RouteDetailView(routeId: route.id, fallbackName: route.name)
} label: {
RouteRowView(route: route)
}
}
}
}
.refreshable { await load() }
@ -42,13 +58,25 @@ struct RoutesListView: View {
}
private func load() async {
isLoading = true
// 1) Cache-first: sofort anzeigen (auch offline)
if routes.isEmpty {
let cached = OfflineCache.cachedRoutes(in: ctx)
if !cached.isEmpty { routes = cached }
}
isLoading = routes.isEmpty
errorMessage = nil
defer { isLoading = false }
// 2) Offline gesicherte Touren hochladen (falls online)
await OfflineCache.syncPendingRoutes(in: ctx)
// 3) Netzwerk Cache aktualisieren; bei Fehler bleibt der Cache stehen
do {
routes = try await APIClient.shared.get("/api/routes")
let fetched: [RouteListItem] = try await APIClient.shared.get("/api/routes")
routes = fetched
OfflineCache.upsertRoutes(fetched, in: ctx)
let paths = fetched.flatMap { $0.fotoUrls ?? [] }
Task { await OfflineCache.prefetchImages(paths: paths, in: ctx) }
} catch {
errorMessage = error.localizedDescription
if routes.isEmpty { errorMessage = error.localizedDescription }
}
}
}
@ -90,6 +118,42 @@ struct RouteRowView: View {
}
}
struct PendingRouteRow: View {
let route: PendingRoute
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(route.name)
.font(.headline)
Spacer()
Image(systemName: "icloud.and.arrow.up")
.foregroundStyle(.orange)
}
HStack(spacing: 12) {
Text(String(format: "%.1f km", route.distanzKm))
Text("\(route.dauerMin) min")
if !route.photos.isEmpty {
Label("\(route.photos.count)", systemImage: "photo")
}
Spacer()
Text("noch nicht hochgeladen")
.foregroundStyle(.orange)
}
.font(.caption)
.foregroundStyle(.secondary)
let track = route.gpsTrack
if track.count >= 2 {
MiniRouteMap(track: track)
.frame(height: 110)
.clipShape(RoundedRectangle(cornerRadius: 10))
}
}
.padding(.vertical, 4)
}
}
enum DateUtil {
/// Parses backend timestamps (SQLite `YYYY-MM-DD HH:MM:SS` or ISO-8601)
/// into a German short date.