import SwiftUI import SwiftData import PhotosUI struct FinishWalkSheet: View { let points: [GPSPoint] let durationSeconds: Int let distanceMeters: Double let initialPhotos: [CapturedPhoto] let onDiscard: () -> Void let onSaved: () -> Void @Environment(\.dismiss) private var dismiss @Environment(\.modelContext) private var modelContext @AppStorage("healthKitSyncEnabled") private var healthKitSyncEnabled = false @State private var name: String @State private var selectedDogIds: Set = [] @State private var dogs: [Dog] = [] @State private var isLoadingDogs = false @State private var photoSelection: [PhotosPickerItem] = [] @State private var photoData: [CapturedPhoto] = [] @State private var loadingPhotos = false @State private var saveState: SaveState = .idle @State private var errorMessage: String? private enum SaveState: Equatable { case idle case savingRoute case uploadingPhotos(done: Int, total: Int) } private let shortDistanceThreshold: Double = 50 // meters init( points: [GPSPoint], durationSeconds: Int, distanceMeters: Double, initialPhotos: [CapturedPhoto] = [], onDiscard: @escaping () -> Void, onSaved: @escaping () -> Void ) { self.points = points self.durationSeconds = durationSeconds self.distanceMeters = distanceMeters self.initialPhotos = initialPhotos self.onDiscard = onDiscard self.onSaved = onSaved let formatter = DateFormatter() formatter.locale = Locale(identifier: "de_DE") formatter.dateStyle = .medium _name = State(initialValue: "Gassi am \(formatter.string(from: .now))") _photoData = State(initialValue: initialPhotos) } var body: some View { NavigationStack { Form { if points.count >= 2 { Section { MiniRouteMap(track: points, lineWidth: 4) .frame(height: 220) .listRowInsets(EdgeInsets()) } } if distanceMeters < shortDistanceThreshold { shortDistanceWarning } Section("Stats") { LabeledContent("Distanz", value: String(format: "%.2f km", distanceMeters / 1000)) LabeledContent("Dauer", value: durationLabel) LabeledContent("Punkte", value: "\(points.count)") } Section("Name") { TextField("Name der Tour", text: $name) } Section("Hunde") { if isLoadingDogs && dogs.isEmpty { HStack { ProgressView(); Text("Lade Hunde…") } } else if dogs.isEmpty { Text("Keine Hunde gefunden. Wird gespeichert ohne Hund-Zuordnung.") .font(.footnote) .foregroundStyle(.secondary) } else { ForEach(dogs) { dog in dogRow(dog) } } } Section { PhotosPicker( selection: $photoSelection, maxSelectionCount: 10, matching: .images ) { Label( photoData.isEmpty ? "Fotos hinzufügen" : "Fotos ändern", systemImage: "photo.badge.plus" ) } if loadingPhotos { HStack { ProgressView(); Text("Lade Fotos…").font(.caption).foregroundStyle(.secondary) } } if !photoData.isEmpty { photoStrip } } header: { Text(photoData.isEmpty ? "Fotos" : "Fotos (\(photoData.count))") } if let errorMessage { Section { Text(errorMessage) .font(.footnote) .foregroundStyle(.red) } } } .navigationTitle("Tour speichern") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Verwerfen", role: .destructive) { onDiscard() dismiss() } .disabled(saveState != .idle) } ToolbarItem(placement: .confirmationAction) { saveToolbarItem } } .task { await loadDogs() } .onChange(of: photoSelection) { _, newItems in Task { await loadPhotos(from: newItems) } } .interactiveDismissDisabled(saveState != .idle) } } @ViewBuilder private var saveToolbarItem: some View { switch saveState { case .idle: Button("Speichern") { Task { await save() } } .disabled(canSave == false) case .savingRoute: ProgressView() case .uploadingPhotos(let done, let total): Text("\(done)/\(total)") .font(.caption.monospacedDigit()) .foregroundStyle(.secondary) } } private var shortDistanceWarning: some View { Section { HStack(spacing: 10) { Image(systemName: "exclamationmark.triangle.fill") .foregroundStyle(.orange) Text("Sehr kurze Tour (\(Int(distanceMeters)) m). Du kannst trotzdem speichern.") .font(.footnote) } } } private var photoStrip: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { ForEach(Array(photoData.enumerated()), id: \.offset) { _, photo in if let img = UIImage(data: photo.data) { Image(uiImage: img) .resizable() .scaledToFill() .frame(width: 80, height: 80) .clipShape(RoundedRectangle(cornerRadius: 8)) .overlay(alignment: .topTrailing) { if photo.location != nil { Image(systemName: "location.fill") .font(.caption2) .foregroundStyle(.white) .padding(4) .background(Color.accentColor, in: Circle()) .padding(4) } } } } } } } private func dogRow(_ dog: Dog) -> some View { let selected = selectedDogIds.contains(dog.id) return HStack { Image(systemName: selected ? "checkmark.circle.fill" : "circle") .foregroundStyle(selected ? Color.accentColor : .secondary) VStack(alignment: .leading) { Text(dog.name) if let rasse = dog.rasse, !rasse.isEmpty { Text(rasse).font(.caption).foregroundStyle(.secondary) } } Spacer() } .contentShape(Rectangle()) .onTapGesture { if selected { selectedDogIds.remove(dog.id) } else { selectedDogIds.insert(dog.id) } } } private var durationLabel: String { let mins = durationSeconds / 60 let secs = durationSeconds % 60 if mins >= 60 { return "\(mins / 60) h \(mins % 60) min" } return "\(mins) min \(secs) s" } private var canSave: Bool { !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && points.count >= 2 && saveState == .idle } private func loadDogs() async { isLoadingDogs = true defer { isLoadingDogs = false } do { let fetched: [Dog] = try await APIClient.shared.get("/api/dogs") self.dogs = fetched if fetched.count == 1 { selectedDogIds = [fetched[0].id] } } catch { print("FinishWalkSheet loadDogs failed: \(error)") } } private func loadPhotos(from items: [PhotosPickerItem]) async { loadingPhotos = true defer { loadingPhotos = false } var loaded: [CapturedPhoto] = initialPhotos // keep camera-captured ones for item in items { if let data = try? await item.loadTransferable(type: Data.self) { loaded.append(CapturedPhoto(data: data, location: nil)) } } photoData = loaded } private func save() async { errorMessage = nil saveState = .savingRoute let body = RouteCreateBody( name: name.trimmingCharacters(in: .whitespacesAndNewlines), gpsTrack: points, distanzKm: distanceMeters / 1000, dauerMin: max(1, durationSeconds / 60), dogIds: Array(selectedDogIds), isPublic: false ) let route: RouteDetail do { route = try await APIClient.shared.post("/api/routes", body: body) } catch { errorMessage = error.localizedDescription saveState = .idle return } if !photoData.isEmpty { for (index, photo) in photoData.enumerated() { saveState = .uploadingPhotos(done: index, total: photoData.count) let resized = ImageResize.resizedJPEG(from: photo.data) do { let responseData = try await APIClient.shared.uploadFile( "/api/routes/\(route.id)/photo", filename: "photo_\(index + 1).jpg", data: resized ) // Persist GPS location of this photo if we have one if let coord = photo.location, let obj = try? JSONSerialization.jsonObject(with: responseData) as? [String: Any], let fotoUrl = obj["foto_url"] as? String { let loc = PhotoLocation( routeId: route.id, photoUrl: fotoUrl, lat: coord.lat, lon: coord.lon ) modelContext.insert(loc) } } catch { errorMessage = "Tour ist gespeichert, aber Foto \(index + 1) konnte nicht hochgeladen werden: \(error.localizedDescription)" saveState = .idle onSaved() return } } try? modelContext.save() saveState = .uploadingPhotos(done: photoData.count, total: photoData.count) } // Apple Health sync (only if user opted in) if healthKitSyncEnabled, points.count >= 2 { let endedAt = Date.now let startedAt = endedAt.addingTimeInterval(-Double(durationSeconds)) await WalkHealthSync.shared.saveWalk( points: points, startedAt: startedAt, endedAt: endedAt, distanceMeters: distanceMeters ) } onSaved() dismiss() } }