431 lines
15 KiB
Swift
431 lines
15 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
|
|
@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)
|
|
.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 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)
|
|
}
|
|
}
|