banyaro-ios/BanYaroGo/Views/RoutesListView.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

175 lines
6 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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?
var body: some View {
NavigationStack {
content
.navigationTitle("Touren")
.task { await load() }
}
}
@ViewBuilder
private var content: some View {
if isLoading && routes.isEmpty && pending.isEmpty {
ProgressView()
} else if let error = errorMessage, routes.isEmpty && pending.isEmpty {
ContentUnavailableView(
"Konnte Touren nicht laden",
systemImage: "wifi.slash",
description: Text(error)
)
} else if routes.isEmpty && pending.isEmpty {
ContentUnavailableView(
"Keine Touren",
systemImage: "map",
description: Text("Zeichne deine erste Gassi-Tour über den Tab „Aufnehmen“ auf — oder importiere einen GPX-Track.")
)
} else {
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() }
}
}
private func load() async {
// 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 {
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 {
if routes.isEmpty { errorMessage = error.localizedDescription }
}
}
}
struct RouteRowView: View {
let route: RouteListItem
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(route.name)
.font(.headline)
Spacer()
if let km = route.distanzKm {
Text(String(format: "%.1f km", km))
.font(.subheadline.monospacedDigit())
.foregroundStyle(.secondary)
}
}
HStack(spacing: 12) {
if let mins = route.dauerMin {
Label("\(mins) min", systemImage: "clock")
}
if let date = route.createdAt {
Text(DateUtil.format(date))
}
Spacer()
}
.font(.caption)
.foregroundStyle(.secondary)
if route.previewTrack.count >= 2 {
MiniRouteMap(track: route.previewTrack)
.frame(height: 110)
.clipShape(RoundedRectangle(cornerRadius: 10))
}
}
.padding(.vertical, 4)
}
}
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.
static func format(_ input: String) -> String {
let parser = DateFormatter()
parser.locale = Locale(identifier: "en_US_POSIX")
parser.timeZone = TimeZone(identifier: "UTC")
for format in ["yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd'T'HH:mm:ss", "yyyy-MM-dd'T'HH:mm:ssZ"] {
parser.dateFormat = format
if let date = parser.date(from: input) {
let out = DateFormatter()
out.locale = Locale(identifier: "de_DE")
out.dateStyle = .medium
return out.string(from: date)
}
}
return String(input.prefix(10))
}
}