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.
176 lines
5.9 KiB
Swift
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)
|
|
}
|
|
}
|