import SwiftUI import MapKit import PhotosUI struct VerloreneHundeView: View { @State private var location = OneShotLocation() @State private var lostDogs: [LostDog] = [] @State private var isLoading = false @State private var errorMessage: String? @State private var showReport = false @State private var cameraPosition: MapCameraPosition = .automatic @State private var tappedDog: LostDog? var body: some View { Group { if let coord = location.coordinate { content(at: coord) } else if location.error != nil { ContentUnavailableView( "Kein Standort", systemImage: "location.slash", description: Text(location.error ?? "") ) } else { ProgressView("Hole Standort…") } } .navigationTitle("Verlorene Hunde") .navigationBarTitleDisplayMode(.inline) .toolbar { if location.coordinate != nil { ToolbarItem(placement: .topBarTrailing) { Button { showReport = true } label: { Label("Melden", systemImage: "exclamationmark.bubble") } } } } .sheet(isPresented: $showReport) { if let coord = location.coordinate { ReportLostDogSheet(coord: coord) { await load() } } } .sheet(item: $tappedDog) { dog in NavigationStack { LostDogDetailView(dog: dog) .toolbar { ToolbarItem(placement: .topBarTrailing) { Button("Schließen") { tappedDog = nil } } } } } .task { location.request() await load() } .onChange(of: location.coordinate?.latitude) { _, _ in if let c = location.coordinate { centerOn(c) } Task { await load() } } .refreshable { await load() } } private func content(at coord: CLLocationCoordinate2D) -> some View { VStack(spacing: 0) { Map(position: $cameraPosition) { UserAnnotation() ForEach(lostDogs) { dog in Annotation(dog.name, coordinate: CLLocationCoordinate2D(latitude: dog.lat, longitude: dog.lon) ) { Button { tappedDog = dog } label: { Image(systemName: "magnifyingglass.circle.fill") .font(.title2) .foregroundStyle(.white, Color.accentColor) .background(.white, in: Circle()) .shadow(radius: 2) } .buttonStyle(.plain) } } } .frame(height: 260) .ignoresSafeArea(edges: .top) .overlay(alignment: .topTrailing) { Button { centerOn(coord) } label: { Image(systemName: "location.fill") .font(.callout.bold()) .foregroundStyle(Color.accentColor) .padding(10) .background(.thinMaterial, in: Circle()) .shadow(radius: 2) } .padding(.top, 60) .padding(.trailing, 12) } if isLoading && lostDogs.isEmpty { ProgressView().padding(.top, 30) Spacer() } else if let errorMessage, lostDogs.isEmpty { ContentUnavailableView( "Konnte nicht laden", systemImage: "wifi.slash", description: Text(errorMessage) ) .padding(.top, 30) Spacer() } else if lostDogs.isEmpty { ContentUnavailableView( "Keine vermissten Hunde", systemImage: "checkmark.circle", description: Text("In 25 km Umkreis sind aktuell keine Vermisstmeldungen aktiv.") ) .padding(.top, 30) Spacer() } else { List(lostDogs) { dog in NavigationLink { LostDogDetailView(dog: dog) } label: { LostDogRow(dog: dog) } } .listStyle(.plain) } } } private func centerOn(_ coord: CLLocationCoordinate2D) { withAnimation(.easeInOut(duration: 0.4)) { cameraPosition = .region(MKCoordinateRegion( center: coord, span: MKCoordinateSpan(latitudeDelta: 0.1, longitudeDelta: 0.1) )) } } private func load() async { isLoading = true errorMessage = nil defer { isLoading = false } do { var path = "/api/lost?radius_km=25" if let coord = location.coordinate { path = "/api/lost?lat=\(coord.latitude)&lon=\(coord.longitude)&radius_km=25" } lostDogs = try await APIClient.shared.get(path) } catch { errorMessage = error.localizedDescription } } } private struct LostDogRow: View { let dog: LostDog var body: some View { HStack(spacing: 12) { avatar .frame(width: 56, height: 56) .clipShape(RoundedRectangle(cornerRadius: 8)) VStack(alignment: .leading, spacing: 2) { HStack { Text(dog.name).font(.headline) Spacer() if let d = dog.distanzM { Text(distLabel(d)) .font(.caption.monospacedDigit()) .foregroundStyle(.secondary) } } if let r = dog.rasse, !r.isEmpty { Text(r).font(.caption).foregroundStyle(.secondary) } Text(dog.beschreibung) .font(.caption) .foregroundStyle(.secondary) .lineLimit(2) } } .padding(.vertical, 4) } @ViewBuilder private var avatar: some View { if let path = dog.fotoUrl, let url = URL(string: "https://banyaro.app\(path)") { AsyncImage(url: url) { phase in switch phase { case .success(let img): img.resizable().scaledToFill() default: placeholder } } } else { placeholder } } private var placeholder: some View { ZStack { Color.accentColor.opacity(0.15) Image(systemName: "magnifyingglass") .foregroundStyle(Color.accentColor) } } private func distLabel(_ m: Int) -> String { if m >= 1000 { return String(format: "%.1f km", Double(m) / 1000) } return "\(m) m" } } private struct ReportLostDogSheet: View { @Environment(\.dismiss) private var dismiss let coord: CLLocationCoordinate2D let onSaved: () async -> Void @State private var name = "" @State private var rasse = "" @State private var beschreibung = "" @State private var dogs: [Dog] = [] @State private var ownDogId: Int? @State private var photoSelection: [PhotosPickerItem] = [] @State private var photoData: Data? @State private var saveState: SaveState = .idle @State private var errorMessage: String? private enum SaveState: Equatable { case idle case savingEntry case uploadingPhoto } var body: some View { NavigationStack { Form { Section("Hund") { TextField("Name (z. B. Bello)", text: $name) TextField("Rasse (optional)", text: $rasse) if !dogs.isEmpty { Picker("Eigener Hund", selection: $ownDogId) { Text("Nicht meiner").tag(Int?.none) ForEach(dogs) { dog in Text(dog.name).tag(Int?.some(dog.id)) } } } } Section("Beschreibung") { TextField("Auffälligkeiten, Halsband, Verhalten — alles was beim Wiederfinden hilft.", text: $beschreibung, axis: .vertical) .lineLimit(3...8) } Section { PhotosPicker(selection: $photoSelection, maxSelectionCount: 1, matching: .images) { Label(photoData == nil ? "Foto hinzufügen" : "Foto ändern", systemImage: "photo.badge.plus") } if let photoData, let img = UIImage(data: photoData) { Image(uiImage: img) .resizable() .scaledToFill() .frame(maxHeight: 200) .clipShape(RoundedRectangle(cornerRadius: 8)) } } header: { Text("Foto") } footer: { Text("Ein aktuelles Foto erhöht die Chance, dass jemand den Hund erkennt.") .font(.caption2) } Section("Letzter bekannter Standort") { Text(String(format: "%.5f, %.5f", coord.latitude, coord.longitude)) .font(.caption.monospacedDigit()) .foregroundStyle(.secondary) } if let errorMessage { Section { Text(errorMessage).font(.footnote).foregroundStyle(.red) } } } .navigationTitle("Vermisst melden") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Abbrechen") { dismiss() } .disabled(saveState != .idle) } ToolbarItem(placement: .confirmationAction) { saveToolbarItem } } .onChange(of: photoSelection) { _, items in Task { await loadPhoto(from: items.first) } } .task { dogs = (try? await APIClient.shared.get("/api/dogs")) ?? [] } .interactiveDismissDisabled(saveState != .idle) } } @ViewBuilder private var saveToolbarItem: some View { switch saveState { case .idle: Button("Melden") { Task { await save() } } .disabled(name.trimmingCharacters(in: .whitespaces).isEmpty || beschreibung.trimmingCharacters(in: .whitespaces).count < 3) case .savingEntry, .uploadingPhoto: ProgressView() } } private func loadPhoto(from item: PhotosPickerItem?) async { guard let item else { return } photoData = try? await item.loadTransferable(type: Data.self) } private func save() async { errorMessage = nil saveState = .savingEntry let body = LostDogCreateBody( name: name.trimmingCharacters(in: .whitespaces), rasse: rasse.trimmingCharacters(in: .whitespaces).isEmpty ? nil : rasse, beschreibung: beschreibung.trimmingCharacters(in: .whitespacesAndNewlines), lat: coord.latitude, lon: coord.longitude, dogId: ownDogId ) let created: LostDog do { created = try await APIClient.shared.post("/api/lost", body: body) } catch { errorMessage = error.localizedDescription saveState = .idle return } if let photoData { saveState = .uploadingPhoto let resized = ImageResize.resizedJPEG(from: photoData) do { _ = try await APIClient.shared.uploadFile( "/api/lost/\(created.id)/foto", filename: "lost_\(created.id).jpg", data: resized ) } catch { errorMessage = "Meldung ist gespeichert, aber Foto-Upload fehlgeschlagen: \(error.localizedDescription)" await onSaved() saveState = .idle return } } await onSaved() dismiss() } } private struct LostDogDetailView: View { let dog: LostDog var body: some View { ScrollView { VStack(alignment: .leading, spacing: 16) { if let path = dog.fotoUrl, let url = URL(string: "https://banyaro.app\(path)") { AsyncImage(url: url) { phase in switch phase { case .success(let img): img.resizable().scaledToFit() default: Rectangle().fill(.gray.opacity(0.15)).frame(height: 200) } } .frame(maxHeight: 280) .clipShape(RoundedRectangle(cornerRadius: 12)) } VStack(alignment: .leading, spacing: 6) { Text(dog.name).font(.title.bold()) if let r = dog.rasse, !r.isEmpty { Text(r).font(.headline).foregroundStyle(.secondary) } } Text(dog.beschreibung).font(.body) Map(initialPosition: .region(MKCoordinateRegion( center: CLLocationCoordinate2D(latitude: dog.lat, longitude: dog.lon), span: MKCoordinateSpan(latitudeDelta: 0.05, longitudeDelta: 0.05) ))) { Annotation(dog.name, coordinate: CLLocationCoordinate2D(latitude: dog.lat, longitude: dog.lon)) { Image(systemName: "magnifyingglass.circle.fill") .font(.title) .foregroundStyle(.white, Color.accentColor) .background(.white, in: Circle()) } } .frame(height: 240) .clipShape(RoundedRectangle(cornerRadius: 12)) .allowsHitTesting(false) if let m = dog.melderName { Label("Gemeldet von \(m)", systemImage: "person.fill") .font(.caption).foregroundStyle(.secondary) } } .padding() } .navigationTitle("Vermisst") .navigationBarTitleDisplayMode(.inline) } }