banyaro-ios/BanYaroGo/Views/GiftkoederView.swift

226 lines
7.9 KiB
Swift

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)
.onAppear { centerOn(coord) }
.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
}
}
}