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).
175 lines
6 KiB
Swift
175 lines
6 KiB
Swift
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))
|
||
}
|
||
}
|