banyaro-ios/BanYaroGo/Views/VerloreneHundeView.swift
rene fb00468c8c Verlorene Hunde: Melden-Button + Sheet analog zu Giftköder
Toolbar-Button rechts oben (exclamationmark.bubble), Sheet mit:
- Name (Pflicht), Rasse (optional), Eigener-Hund-Picker (optional)
- Beschreibung (Pflicht, min 3 Zeichen)
- PhotosPicker für ein Foto
- Standort vom OneShotLocation (read-only Anzeige)
- POST /api/lost, danach optional POST /api/lost/{id}/foto (multipart,
  resized via ImageResize)
2026-05-30 13:34:10 +02:00

349 lines
12 KiB
Swift

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
var body: some View {
content
.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() }
}
}
.task {
location.request()
await load()
}
.onChange(of: location.coordinate?.latitude) { _, _ in
Task { await load() }
}
.refreshable { await load() }
}
@ViewBuilder
private var content: some View {
if isLoading && lostDogs.isEmpty {
ProgressView()
} else if let errorMessage, lostDogs.isEmpty {
ContentUnavailableView("Konnte nicht laden", systemImage: "wifi.slash", description: Text(errorMessage))
} else if lostDogs.isEmpty {
ContentUnavailableView(
"Keine vermissten Hunde",
systemImage: "checkmark.circle",
description: Text("In 25 km Umkreis sind aktuell keine Vermisstmeldungen aktiv.")
)
} else {
List(lostDogs) { dog in
NavigationLink {
LostDogDetailView(dog: dog)
} label: {
LostDogRow(dog: dog)
}
}
}
}
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)
}
}