import SwiftUI import MapKit struct GiftkoederView: View { @State private var location = OneShotLocation() @State private var alerts: [PoisonAlert] = [] @State private var isLoading = false @State private var errorMessage: String? @State private var showReport = false @State private var cameraPosition: MapCameraPosition = .automatic 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("Giftköder") .navigationBarTitleDisplayMode(.inline) .toolbar { if location.coordinate != nil { ToolbarItem(placement: .topBarTrailing) { Button { showReport = true } label: { Label("Melden", systemImage: "exclamationmark.bubble") } } } } .task { location.request() } .onChange(of: location.coordinate?.latitude) { _, _ in if let c = location.coordinate { centerOn(c) } Task { await load() } } .sheet(isPresented: $showReport) { if let coord = location.coordinate { ReportPoisonSheet(coord: coord) { await load() } } } } private func content(at coord: CLLocationCoordinate2D) -> some View { VStack(spacing: 0) { Map(position: $cameraPosition) { UserAnnotation() ForEach(alerts) { alert in Annotation(alert.typ ?? "Giftköder", coordinate: CLLocationCoordinate2D(latitude: alert.lat, longitude: alert.lon) ) { Image(systemName: "exclamationmark.octagon.fill") .font(.title2) .foregroundStyle(.red) .background(.white, in: Circle()) .shadow(radius: 2) } } } .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 alerts.isEmpty && !isLoading { ContentUnavailableView( "Keine Meldungen im Umkreis", systemImage: "checkmark.shield", description: Text("In 5 km Umkreis sind aktuell keine aktiven Warnungen.") ) .padding(.top, 30) Spacer() } else { List(alerts) { alert in PoisonRow(alert: alert) } .listStyle(.plain) } } } private func centerOn(_ coord: CLLocationCoordinate2D) { withAnimation(.easeInOut(duration: 0.4)) { cameraPosition = .region(MKCoordinateRegion( center: coord, span: MKCoordinateSpan(latitudeDelta: 0.05, longitudeDelta: 0.05) )) } } private func load() async { guard let coord = location.coordinate else { return } isLoading = true errorMessage = nil defer { isLoading = false } do { alerts = try await APIClient.shared.get( "/api/poison?lat=\(coord.latitude)&lon=\(coord.longitude)&radius=5000" ) } catch { errorMessage = error.localizedDescription } } } private struct PoisonRow: View { let alert: PoisonAlert var body: some View { HStack(spacing: 12) { Image(systemName: "exclamationmark.octagon.fill") .foregroundStyle(.red) .frame(width: 28) VStack(alignment: .leading, spacing: 2) { HStack { Text((alert.typ ?? "Unbekannt").capitalized) .font(.subheadline.bold()) Spacer() if let d = alert.distanzM { Text(distLabel(d)) .font(.caption.monospacedDigit()) .foregroundStyle(.secondary) } } if let b = alert.beschreibung, !b.isEmpty { Text(b).font(.caption).foregroundStyle(.secondary).lineLimit(2) } if let m = alert.melderName { Text("Gemeldet von \(m)").font(.caption2).foregroundStyle(.tertiary) } } } .padding(.vertical, 4) } private func distLabel(_ m: Int) -> String { if m >= 1000 { return String(format: "%.1f km", Double(m) / 1000) } return "\(m) m" } } private struct ReportPoisonSheet: View { @Environment(\.dismiss) private var dismiss let coord: CLLocationCoordinate2D let onSaved: () async -> Void @State private var typ = "unbekannt" @State private var beschreibung = "" @State private var isSaving = false @State private var errorMessage: String? private let typen = ["unbekannt", "wurst", "tabletten", "glas", "metall", "wurfdose", "rattengift"] var body: some View { NavigationStack { Form { Section("Standort") { Text(String(format: "%.5f, %.5f", coord.latitude, coord.longitude)) .font(.caption.monospacedDigit()) } Section("Art") { Picker("Typ", selection: $typ) { ForEach(typen, id: \.self) { Text($0.capitalized) } } } Section("Beschreibung (optional)") { TextField("Was gefunden? Wo genau? Hinweise für andere…", text: $beschreibung, axis: .vertical) .lineLimit(3...6) } if let errorMessage { Section { Text(errorMessage).font(.footnote).foregroundStyle(.red) } } } .navigationTitle("Giftköder melden") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Abbrechen") { dismiss() }.disabled(isSaving) } ToolbarItem(placement: .confirmationAction) { if isSaving { ProgressView() } else { Button("Melden") { Task { await save() } } } } } } } private func save() async { isSaving = true errorMessage = nil defer { isSaving = false } let body = PoisonCreateBody( lat: coord.latitude, lon: coord.longitude, beschreibung: beschreibung.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : beschreibung, typ: typ ) do { let _: PoisonAlert = try await APIClient.shared.post("/api/poison", body: body) await onSaved() dismiss() } catch { errorMessage = error.localizedDescription } } }