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.
This commit is contained in:
parent
f1b3ff4035
commit
68b084be97
11 changed files with 1547 additions and 0 deletions
203
BanYaroGo/Views/GiftkoederView.swift
Normal file
203
BanYaroGo/Views/GiftkoederView.swift
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
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
|
||||
|
||||
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
|
||||
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(initialPosition: .region(MKCoordinateRegion(
|
||||
center: coord,
|
||||
span: MKCoordinateSpan(latitudeDelta: 0.05, longitudeDelta: 0.05)
|
||||
))) {
|
||||
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)
|
||||
|
||||
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 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
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue