banyaro-ios/BanYaroGo/Views/VerloreneHundeView.swift
rene 68b084be97 Sechs Offline-Features: Erste Hilfe, Ausgaben, Wetter, Gassi-Zeiten, Giftköder, Verlorene
Pitch-Karte erweitert um die neuen Features (sowie Hundesitting, Züchter).

Neue DTOs in DTOs.swift:
- Expense + ExpenseCreateBody
- GassiZeit + GassiZeitCreateBody (mit wochentage [String], radius_m)
- PoisonAlert + PoisonCreateBody
- LostDog + LostDogCreateBody
- WeatherForecast + WeatherDay (mit asphalt_temp, zecken, pollen-Felder)

Neue Views:
- ErsteHilfeView + Detail: sechs Notfall-Topics (Vergiftung, Hitzschlag,
  Wunden, Atemnot, Krampfanfall, Magendrehung) — komplett offline, kein API
- AusgabenView: Liste mit Total, AddExpenseSheet mit Kategorie/Betrag/
  Datum/Hund-Picker
- WetterView: One-Shot Location + /api/weather/forecast, 7-Tage-Vorhersage
  mit Hunde-Tipps (Hitze ab 25°/30°, Frost, Asphalt ≥50°, Zecken, Regen)
- GassiZeitenView: eigene Zeiten + Add-Sheet (Wochentag-Picker, Hund-
  Auswahl), automatische lokale UNCalendarNotifications via Scheduler
- GiftkoederView: Map mit Pins + Liste in 5km Umkreis, Report-Sheet mit
  Typ-Auswahl
- VerloreneHundeView: Liste mit Foto/Distanz, Detail mit Karte

Support:
- OneShotLocation: kleiner CLLocationManager-Wrapper für einmalige
  Positionsabfrage (Wetter, Giftköder)
- GassiZeitenScheduler: UNCalendarNotificationTrigger pro Wochentag,
  Identifier-Schema "gz-{id}-{weekday}"

Navigation: Section "Hund & Alltag" im Mehr-Tab mit NavigationLinks zu
allen sechs neuen Ansichten.
2026-05-30 12:03:24 +02:00

176 lines
5.9 KiB
Swift

import SwiftUI
import MapKit
struct VerloreneHundeView: View {
@State private var location = OneShotLocation()
@State private var lostDogs: [LostDog] = []
@State private var isLoading = false
@State private var errorMessage: String?
var body: some View {
content
.navigationTitle("Verlorene Hunde")
.navigationBarTitleDisplayMode(.inline)
.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 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)
}
}