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