import SwiftUI import SwiftData import PhotosUI struct RouteDetailView: View { let routeId: Int let fallbackName: String @Environment(\.dismiss) private var dismiss @Environment(AuthSession.self) private var auth @Environment(\.modelContext) private var ctx @Query private var allPhotoLocations: [PhotoLocation] @State private var detail: RouteDetail? @State private var isLoading = false @State private var errorMessage: String? @State private var tappedPhotoUrl: String? @State private var newPhotoSelection: [PhotosPickerItem] = [] @State private var isUploadingPhoto = false @State private var photoUploadProgress: (done: Int, total: Int) = (0, 0) @State private var photoErrorMessage: String? @State private var showEditSheet = false @State private var showDeleteAlert = false @State private var isDeleting = false @State private var shareItems: [Any]? @State private var isGeneratingShareImage = false private var isOwn: Bool { guard let detail, let myId = auth.profile?.id else { return false } return detail.userId == myId } private var photoLocations: [PhotoLocation] { allPhotoLocations.filter { $0.routeId == routeId } } var body: some View { ScrollView { VStack(alignment: .leading, spacing: 16) { if let detail { MiniRouteMap( track: detail.gpsTrack, lineWidth: 4, photoLocations: photoLocations, onPhotoTap: { loc in tappedPhotoUrl = loc.photoUrl } ) .frame(height: 320) .clipShape(RoundedRectangle(cornerRadius: 16)) .padding(.horizontal) HStack(spacing: 12) { StatTile(value: formatKm(detail.distanzKm), label: "Distanz", icon: "ruler") StatTile(value: formatMin(detail.dauerMin), label: "Dauer", icon: "clock") StatTile(value: "\(detail.gpsTrack.count)", label: "Punkte", icon: "point.3.connected.trianglepath.dotted") } .padding(.horizontal) if let beschreibung = detail.beschreibung, !beschreibung.isEmpty { Text(beschreibung) .font(.body) .padding(.horizontal) } photosSection(for: detail) .padding(.horizontal) Spacer(minLength: 24) } else if isLoading { ProgressView().padding(.top, 80) } else if let error = errorMessage { ContentUnavailableView( "Fehler", systemImage: "exclamationmark.triangle", description: Text(error) ) .padding(.top, 60) } } } .navigationTitle(detail?.name ?? fallbackName) .navigationBarTitleDisplayMode(.inline) .task { await load() } .onChange(of: newPhotoSelection) { _, items in guard !items.isEmpty else { return } Task { await uploadSelected(items: items) } } .toolbar { if let detail { ToolbarItem(placement: .topBarTrailing) { Menu { Button { Task { await shareAsImage() } } label: { Label("Als Bild teilen", systemImage: "photo") } .disabled(isGeneratingShareImage) Button { shareAsGPX() } label: { Label("Als GPX-Datei teilen", systemImage: "doc.text") } if isOwn { Divider() Button { showEditSheet = true } label: { Label("Bearbeiten", systemImage: "pencil") } Button(role: .destructive) { showDeleteAlert = true } label: { Label("Löschen", systemImage: "trash") } } } label: { if isGeneratingShareImage { ProgressView() } else { Image(systemName: "ellipsis.circle") } } .disabled(isDeleting) .accessibilityIdentifier("routeDetailMenu_\(detail.id)") } } } .sheet(isPresented: $showEditSheet) { if let detail { EditRouteSheet(detail: detail) { updated in self.detail = updated } } } .alert("Tour wirklich löschen?", isPresented: $showDeleteAlert) { Button("Abbrechen", role: .cancel) {} Button("Löschen", role: .destructive) { Task { await deleteRoute() } } } message: { Text("Die Tour wird unwiderruflich gelöscht — auch alle Fotos.") } .sheet(item: Binding( get: { tappedPhotoUrl.map(IdentifiedURL.init) }, set: { tappedPhotoUrl = $0?.value } )) { item in PhotoViewerSheet(path: item.value) } .sheet(isPresented: Binding( get: { shareItems != nil }, set: { if !$0 { shareItems = nil } } )) { if let items = shareItems { ShareSheet(items: items) } } } private func shareAsImage() async { guard let detail else { return } isGeneratingShareImage = true defer { isGeneratingShareImage = false } if let img = await RouteShareImage.render(detail: detail) { shareItems = [img] } } private func shareAsGPX() { guard let detail else { return } if let url = GPXExporter.write(detail: detail) { shareItems = [url] } } @ViewBuilder private func photosSection(for detail: RouteDetail) -> some View { let urls = detail.fotoUrls ?? [] VStack(alignment: .leading, spacing: 8) { HStack { Text(urls.isEmpty ? "Fotos" : "Fotos (\(urls.count))") .font(.headline) Spacer() PhotosPicker( selection: $newPhotoSelection, maxSelectionCount: 5, matching: .images ) { Label("Foto hinzufügen", systemImage: "photo.badge.plus") .font(.subheadline) } .disabled(isUploadingPhoto) } if isUploadingPhoto { HStack(spacing: 8) { ProgressView() Text("Lade \(photoUploadProgress.done + 1)/\(photoUploadProgress.total)…") .font(.caption) .foregroundStyle(.secondary) } } if let photoErrorMessage { Text(photoErrorMessage) .font(.caption) .foregroundStyle(.red) } if !urls.isEmpty { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 10) { ForEach(urls, id: \.self) { path in photoThumb(path) } } } } else if !isUploadingPhoto { Text("Noch keine Fotos. Über den Button oben kannst du welche hinzufügen.") .font(.footnote) .foregroundStyle(.secondary) } } } private func photoThumb(_ path: String) -> some View { CachedAsyncImage(path: path) { img in img.resizable().scaledToFill() } placeholder: { Rectangle().fill(.gray.opacity(0.15)) } .frame(width: 160, height: 160) .clipShape(RoundedRectangle(cornerRadius: 10)) } private func load() async { // 1) Cache-first: gespeicherten Detail-Stand zeigen (auch offline) if detail == nil, let cached = OfflineCache.cachedRouteDetail(id: routeId, in: ctx) { detail = cached } isLoading = (detail == nil) errorMessage = nil defer { isLoading = false } // 2) Netzwerk → Cache aktualisieren; bei Fehler bleibt der Cache stehen do { let fetched: RouteDetail = try await APIClient.shared.get("/api/routes/\(routeId)") detail = fetched OfflineCache.upsertRouteDetail(fetched, in: ctx) let paths = fetched.fotoUrls ?? [] Task { await OfflineCache.prefetchImages(paths: paths, in: ctx) } } catch { if detail == nil { errorMessage = error.localizedDescription } } } private func uploadSelected(items: [PhotosPickerItem]) async { isUploadingPhoto = true photoErrorMessage = nil photoUploadProgress = (0, items.count) defer { isUploadingPhoto = false newPhotoSelection = [] } for (index, item) in items.enumerated() { photoUploadProgress = (index, items.count) guard let raw = try? await item.loadTransferable(type: Data.self) else { continue } let resized = ImageResize.resizedJPEG(from: raw) do { try await APIClient.shared.uploadFile( "/api/routes/\(routeId)/photo", filename: "photo_\(Int(Date.now.timeIntervalSince1970))_\(index + 1).jpg", data: resized ) } catch { photoErrorMessage = "Foto \(index + 1) konnte nicht hochgeladen werden: \(error.localizedDescription)" break } } // Refresh detail to pick up the new foto_urls. await load() } private func deleteRoute() async { isDeleting = true defer { isDeleting = false } do { try await APIClient.shared.delete("/api/routes/\(routeId)") dismiss() } catch { errorMessage = error.localizedDescription } } private func formatKm(_ km: Double?) -> String { guard let km else { return "—" } return String(format: "%.2f km", km) } private func formatMin(_ mins: Int?) -> String { guard let mins else { return "—" } if mins >= 60 { return "\(mins / 60) h \(mins % 60) min" } return "\(mins) min" } } /// Wrapper so we can use sheet(item:) with a plain String URL path. private struct IdentifiedURL: Identifiable { let value: String var id: String { value } } private struct PhotoViewerSheet: View { let path: String @Environment(\.dismiss) private var dismiss var body: some View { NavigationStack { ZStack { Color.black.ignoresSafeArea() if !path.isEmpty { CachedAsyncImage(path: path) { img in img.resizable().scaledToFit() } placeholder: { ProgressView().tint(.white) } } } .toolbar { ToolbarItem(placement: .topBarTrailing) { Button { dismiss() } label: { Image(systemName: "xmark") } .tint(.white) } } } } } private struct StatTile: View { let value: String let label: String let icon: String var body: some View { VStack(spacing: 6) { Image(systemName: icon) .foregroundStyle(Color.accentColor) Text(value) .font(.headline) Text(label) .font(.caption) .foregroundStyle(.secondary) } .frame(maxWidth: .infinity) .padding(.vertical, 12) .background(.background.secondary, in: RoundedRectangle(cornerRadius: 12)) } }