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
|
|
@ -89,6 +89,128 @@ struct RouteCreateBody: Encodable {
|
|||
let isPublic: Bool
|
||||
}
|
||||
|
||||
// MARK: - Expenses
|
||||
|
||||
struct Expense: Decodable, Identifiable {
|
||||
let id: Int
|
||||
let dogId: Int?
|
||||
let kategorie: String
|
||||
let betrag: Double
|
||||
let datum: String
|
||||
let notiz: String?
|
||||
let dogName: String?
|
||||
}
|
||||
|
||||
struct ExpenseCreateBody: Encodable {
|
||||
let dogId: Int?
|
||||
let kategorie: String
|
||||
let betrag: Double
|
||||
let datum: String
|
||||
let notiz: String?
|
||||
}
|
||||
|
||||
// MARK: - Gassi-Zeiten
|
||||
|
||||
struct GassiZeit: Decodable, Identifiable {
|
||||
let id: Int
|
||||
let dogId: Int?
|
||||
let wochentage: [String]
|
||||
let uhrzeit: String
|
||||
let ortName: String?
|
||||
let lat: Double?
|
||||
let lon: Double?
|
||||
let radiusM: Int?
|
||||
let notiz: String?
|
||||
let aktiv: Int?
|
||||
let distanceM: Int?
|
||||
let isMine: Bool?
|
||||
let userName: String?
|
||||
let dogName: String?
|
||||
let dogRasse: String?
|
||||
}
|
||||
|
||||
struct GassiZeitCreateBody: Encodable {
|
||||
let dogId: Int?
|
||||
let wochentage: [String]
|
||||
let uhrzeit: String
|
||||
let ortName: String?
|
||||
let lat: Double?
|
||||
let lon: Double?
|
||||
let radiusM: Int
|
||||
let notiz: String?
|
||||
}
|
||||
|
||||
// MARK: - Poison
|
||||
|
||||
struct PoisonAlert: Decodable, Identifiable {
|
||||
let id: Int
|
||||
let lat: Double
|
||||
let lon: Double
|
||||
let beschreibung: String?
|
||||
let typ: String?
|
||||
let distanzM: Int?
|
||||
let fotoUrl: String?
|
||||
let melderName: String?
|
||||
let createdAt: String?
|
||||
}
|
||||
|
||||
struct PoisonCreateBody: Encodable {
|
||||
let lat: Double
|
||||
let lon: Double
|
||||
let beschreibung: String?
|
||||
let typ: String
|
||||
}
|
||||
|
||||
// MARK: - Lost Dogs
|
||||
|
||||
struct LostDog: Decodable, Identifiable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let rasse: String?
|
||||
let beschreibung: String
|
||||
let lat: Double
|
||||
let lon: Double
|
||||
let distanzM: Int?
|
||||
let fotoUrl: String?
|
||||
let melderName: String?
|
||||
let createdAt: String?
|
||||
}
|
||||
|
||||
struct LostDogCreateBody: Encodable {
|
||||
let name: String
|
||||
let rasse: String?
|
||||
let beschreibung: String
|
||||
let lat: Double
|
||||
let lon: Double
|
||||
let dogId: Int?
|
||||
}
|
||||
|
||||
// MARK: - Weather
|
||||
|
||||
struct WeatherForecast: Decodable {
|
||||
let days: [WeatherDay]
|
||||
}
|
||||
|
||||
struct WeatherDay: Decodable, Identifiable {
|
||||
let date: String
|
||||
let wday: String?
|
||||
let weathercode: Int?
|
||||
let desc: String?
|
||||
let icon: String?
|
||||
let tempMax: Double?
|
||||
let tempMin: Double?
|
||||
let precipProb: Int?
|
||||
let precipSum: Double?
|
||||
let windKmh: Double?
|
||||
let uvIndex: Double?
|
||||
let sunrise: String?
|
||||
let sunset: String?
|
||||
let asphaltTemp: Double?
|
||||
let zecken: String?
|
||||
|
||||
var id: String { date }
|
||||
}
|
||||
|
||||
/// Patch body for PATCH /api/routes/{id}. Only non-nil fields are encoded.
|
||||
struct RouteUpdateBody: Encodable {
|
||||
var name: String?
|
||||
|
|
|
|||
58
BanYaroGo/Support/GassiZeitenScheduler.swift
Normal file
58
BanYaroGo/Support/GassiZeitenScheduler.swift
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import Foundation
|
||||
import UserNotifications
|
||||
|
||||
/// Schedules local repeating notifications for Gassi-Zeiten so reminders work
|
||||
/// even when the app is offline. One UNCalendarNotificationTrigger per weekday.
|
||||
@MainActor
|
||||
enum GassiZeitenScheduler {
|
||||
static func reschedule(_ z: GassiZeit) async {
|
||||
cancel(forId: z.id)
|
||||
guard z.aktiv != 0 else { return }
|
||||
|
||||
let parts = z.uhrzeit.split(separator: ":")
|
||||
guard parts.count == 2,
|
||||
let h = Int(parts[0]),
|
||||
let m = Int(parts[1])
|
||||
else { return }
|
||||
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = "Gassi-Zeit"
|
||||
content.body = z.notiz?.isEmpty == false ? z.notiz! : "Zeit für deine Gassi-Runde."
|
||||
content.sound = .default
|
||||
|
||||
let center = UNUserNotificationCenter.current()
|
||||
for wt in z.wochentage {
|
||||
let weekday = weekdayNumber(for: wt)
|
||||
guard weekday > 0 else { continue }
|
||||
var comps = DateComponents()
|
||||
comps.weekday = weekday
|
||||
comps.hour = h
|
||||
comps.minute = m
|
||||
let trigger = UNCalendarNotificationTrigger(dateMatching: comps, repeats: true)
|
||||
let request = UNNotificationRequest(
|
||||
identifier: "gz-\(z.id)-\(weekday)",
|
||||
content: content,
|
||||
trigger: trigger
|
||||
)
|
||||
try? await center.add(request)
|
||||
}
|
||||
}
|
||||
|
||||
static func cancel(forId id: Int) {
|
||||
let ids = (1...7).map { "gz-\(id)-\($0)" }
|
||||
UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: ids)
|
||||
}
|
||||
|
||||
private static func weekdayNumber(for code: String) -> Int {
|
||||
switch code.lowercased() {
|
||||
case "so": return 1
|
||||
case "mo": return 2
|
||||
case "di": return 3
|
||||
case "mi": return 4
|
||||
case "do": return 5
|
||||
case "fr": return 6
|
||||
case "sa": return 7
|
||||
default: return 0
|
||||
}
|
||||
}
|
||||
}
|
||||
73
BanYaroGo/Support/OneShotLocation.swift
Normal file
73
BanYaroGo/Support/OneShotLocation.swift
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import Foundation
|
||||
import Observation
|
||||
import CoreLocation
|
||||
|
||||
/// Asks CLLocationManager for the user's current location once. Used by
|
||||
/// Wetter and Giftköder which need a position without the full tracking setup.
|
||||
@Observable
|
||||
@MainActor
|
||||
final class OneShotLocation: NSObject, CLLocationManagerDelegate {
|
||||
private let manager = CLLocationManager()
|
||||
|
||||
var coordinate: CLLocationCoordinate2D?
|
||||
var error: String?
|
||||
var isResolving: Bool = false
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
manager.delegate = self
|
||||
manager.desiredAccuracy = kCLLocationAccuracyHundredMeters
|
||||
}
|
||||
|
||||
func request() {
|
||||
error = nil
|
||||
isResolving = true
|
||||
switch manager.authorizationStatus {
|
||||
case .notDetermined:
|
||||
manager.requestWhenInUseAuthorization()
|
||||
case .denied, .restricted:
|
||||
error = "Standortzugriff verweigert."
|
||||
isResolving = false
|
||||
case .authorizedWhenInUse, .authorizedAlways:
|
||||
manager.requestLocation()
|
||||
@unknown default:
|
||||
error = "Unbekannter Standort-Status."
|
||||
isResolving = false
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func locationManager(
|
||||
_ manager: CLLocationManager,
|
||||
didUpdateLocations locations: [CLLocation]
|
||||
) {
|
||||
guard let loc = locations.first else { return }
|
||||
let c = loc.coordinate
|
||||
Task { @MainActor in
|
||||
self.coordinate = c
|
||||
self.isResolving = false
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError err: Error) {
|
||||
let msg = err.localizedDescription
|
||||
Task { @MainActor in
|
||||
self.error = msg
|
||||
self.isResolving = false
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
||||
let status = manager.authorizationStatus
|
||||
Task { @MainActor in
|
||||
switch status {
|
||||
case .authorizedWhenInUse, .authorizedAlways:
|
||||
manager.requestLocation()
|
||||
case .denied, .restricted:
|
||||
self.error = "Standortzugriff verweigert."
|
||||
self.isResolving = false
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
228
BanYaroGo/Views/AusgabenView.swift
Normal file
228
BanYaroGo/Views/AusgabenView.swift
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
import SwiftUI
|
||||
|
||||
struct AusgabenView: View {
|
||||
@State private var expenses: [Expense] = []
|
||||
@State private var isLoading = false
|
||||
@State private var errorMessage: String?
|
||||
@State private var showAdd = false
|
||||
|
||||
private static let dateFormatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.locale = Locale(identifier: "de_DE")
|
||||
f.dateStyle = .medium
|
||||
return f
|
||||
}()
|
||||
|
||||
private let parseFormatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.locale = Locale(identifier: "en_US_POSIX")
|
||||
f.dateFormat = "yyyy-MM-dd"
|
||||
return f
|
||||
}()
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.navigationTitle("Ausgaben")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button { showAdd = true } label: { Image(systemName: "plus") }
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showAdd) {
|
||||
AddExpenseSheet { Task { await load() } }
|
||||
}
|
||||
.task { await load() }
|
||||
.refreshable { await load() }
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
if isLoading && expenses.isEmpty {
|
||||
ProgressView()
|
||||
} else if let errorMessage, expenses.isEmpty {
|
||||
ContentUnavailableView("Konnte Ausgaben nicht laden", systemImage: "wifi.slash", description: Text(errorMessage))
|
||||
} else if expenses.isEmpty {
|
||||
ContentUnavailableView("Noch keine Ausgaben", systemImage: "eurosign.circle", description: Text("Tippe oben rechts auf +, um eine Ausgabe hinzuzufügen."))
|
||||
} else {
|
||||
List {
|
||||
Section {
|
||||
LabeledContent("Gesamt", value: totalLabel)
|
||||
.font(.headline)
|
||||
}
|
||||
Section {
|
||||
ForEach(expenses) { e in
|
||||
ExpenseRow(expense: e, dateLabel: formatDate(e.datum))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var totalLabel: String {
|
||||
let total = expenses.reduce(0) { $0 + $1.betrag }
|
||||
return String(format: "%.2f €", total)
|
||||
}
|
||||
|
||||
private func formatDate(_ s: String) -> String {
|
||||
if let d = parseFormatter.date(from: String(s.prefix(10))) {
|
||||
return Self.dateFormatter.string(from: d)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
private func load() async {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
defer { isLoading = false }
|
||||
do {
|
||||
expenses = try await APIClient.shared.get("/api/expenses")
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ExpenseRow: View {
|
||||
let expense: Expense
|
||||
let dateLabel: String
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: icon(for: expense.kategorie))
|
||||
.foregroundStyle(Color.accentColor)
|
||||
.frame(width: 28)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(expense.kategorie).font(.subheadline.bold())
|
||||
HStack(spacing: 6) {
|
||||
Text(dateLabel)
|
||||
if let dog = expense.dogName {
|
||||
Text("• \(dog)")
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
if let n = expense.notiz, !n.isEmpty {
|
||||
Text(n).font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Text(String(format: "%.2f €", expense.betrag))
|
||||
.font(.subheadline.monospacedDigit().bold())
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
private func icon(for kategorie: String) -> String {
|
||||
switch kategorie.lowercased() {
|
||||
case "futter", "essen": return "fork.knife"
|
||||
case "tierarzt": return "stethoscope"
|
||||
case "versicherung": return "shield.lefthalf.filled"
|
||||
case "spielzeug": return "tennisball.fill"
|
||||
case "pflege", "fellpflege": return "scissors"
|
||||
case "training", "hundeschule": return "graduationcap.fill"
|
||||
case "leckerli", "snacks": return "carrot.fill"
|
||||
default: return "eurosign.circle"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct AddExpenseSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
let onSaved: () -> Void
|
||||
|
||||
@State private var kategorie = "Futter"
|
||||
@State private var betrag = ""
|
||||
@State private var date = Date()
|
||||
@State private var notiz = ""
|
||||
@State private var dogs: [Dog] = []
|
||||
@State private var dogId: Int?
|
||||
@State private var isSaving = false
|
||||
@State private var errorMessage: String?
|
||||
|
||||
private let kategorien = ["Futter", "Tierarzt", "Versicherung", "Spielzeug", "Pflege", "Training", "Leckerli", "Sonstiges"]
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("Kategorie") {
|
||||
Picker("Kategorie", selection: $kategorie) {
|
||||
ForEach(kategorien, id: \.self) { Text($0) }
|
||||
}
|
||||
}
|
||||
Section("Betrag") {
|
||||
TextField("0,00", text: $betrag)
|
||||
.keyboardType(.decimalPad)
|
||||
}
|
||||
Section("Datum") {
|
||||
DatePicker("Datum", selection: $date, displayedComponents: .date)
|
||||
.environment(\.locale, Locale(identifier: "de_DE"))
|
||||
}
|
||||
Section("Hund (optional)") {
|
||||
Picker("Hund", selection: $dogId) {
|
||||
Text("Ohne Hund").tag(Int?.none)
|
||||
ForEach(dogs) { dog in
|
||||
Text(dog.name).tag(Int?.some(dog.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
Section("Notiz (optional)") {
|
||||
TextField("Notiz", text: $notiz, axis: .vertical)
|
||||
.lineLimit(2...4)
|
||||
}
|
||||
if let errorMessage {
|
||||
Section { Text(errorMessage).font(.footnote).foregroundStyle(.red) }
|
||||
}
|
||||
}
|
||||
.navigationTitle("Neue Ausgabe")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Abbrechen") { dismiss() }.disabled(isSaving)
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
if isSaving { ProgressView() }
|
||||
else { Button("Sichern") { Task { await save() } }.disabled(!canSave) }
|
||||
}
|
||||
}
|
||||
.task { await loadDogs() }
|
||||
}
|
||||
}
|
||||
|
||||
private var canSave: Bool {
|
||||
Double(betrag.replacingOccurrences(of: ",", with: ".")) != nil
|
||||
}
|
||||
|
||||
private func loadDogs() async {
|
||||
dogs = (try? await APIClient.shared.get("/api/dogs")) ?? []
|
||||
}
|
||||
|
||||
private func save() async {
|
||||
isSaving = true
|
||||
errorMessage = nil
|
||||
defer { isSaving = false }
|
||||
|
||||
guard let betragValue = Double(betrag.replacingOccurrences(of: ",", with: ".")) else {
|
||||
errorMessage = "Ungültiger Betrag."
|
||||
return
|
||||
}
|
||||
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
let body = ExpenseCreateBody(
|
||||
dogId: dogId,
|
||||
kategorie: kategorie,
|
||||
betrag: betragValue,
|
||||
datum: formatter.string(from: date),
|
||||
notiz: notiz.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : notiz
|
||||
)
|
||||
|
||||
do {
|
||||
let _: Expense = try await APIClient.shared.post("/api/expenses", body: body)
|
||||
onSaved()
|
||||
dismiss()
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
270
BanYaroGo/Views/ErsteHilfeView.swift
Normal file
270
BanYaroGo/Views/ErsteHilfeView.swift
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
import SwiftUI
|
||||
|
||||
struct ErsteHilfeView: View {
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "phone.fill")
|
||||
.foregroundStyle(.red)
|
||||
VStack(alignment: .leading) {
|
||||
Text("Im Ernstfall sofort den Tierarzt anrufen!").font(.subheadline.bold())
|
||||
Text("Diese Hinweise ersetzen keine tierärztliche Behandlung.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
ForEach(ErsteHilfeContent.topics) { topic in
|
||||
NavigationLink {
|
||||
ErsteHilfeDetailView(topic: topic)
|
||||
} label: {
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: topic.icon)
|
||||
.font(.title3)
|
||||
.foregroundStyle(topic.tint)
|
||||
.frame(width: 32)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(topic.title).font(.headline)
|
||||
Text(topic.summary)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Erste Hilfe")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ErsteHilfeDetailView: View {
|
||||
let topic: ErsteHilfeTopic
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: topic.icon)
|
||||
.font(.system(size: 44))
|
||||
.foregroundStyle(topic.tint)
|
||||
.frame(width: 70, height: 70)
|
||||
.background(topic.tint.opacity(0.15), in: Circle())
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(topic.title).font(.title2.bold())
|
||||
Text(topic.summary)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
ForEach(Array(topic.sections.enumerated()), id: \.offset) { _, section in
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(section.heading).font(.headline)
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ForEach(Array(section.steps.enumerated()), id: \.offset) { i, step in
|
||||
HStack(alignment: .firstTextBaseline, spacing: 10) {
|
||||
Text("\(i + 1).")
|
||||
.font(.subheadline.bold())
|
||||
.foregroundStyle(topic.tint)
|
||||
.frame(width: 20, alignment: .trailing)
|
||||
Text(step)
|
||||
.font(.subheadline)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(.background.secondary, in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
if !topic.warnings.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Label("Sofort zum Tierarzt", systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.red)
|
||||
ForEach(topic.warnings, id: \.self) { w in
|
||||
Text("• \(w)").font(.subheadline)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color.red.opacity(0.08), in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.navigationTitle(topic.title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Content
|
||||
|
||||
struct ErsteHilfeTopic: Identifiable {
|
||||
let id: String
|
||||
let title: String
|
||||
let summary: String
|
||||
let icon: String
|
||||
let tint: Color
|
||||
let sections: [Section]
|
||||
let warnings: [String]
|
||||
|
||||
struct Section {
|
||||
let heading: String
|
||||
let steps: [String]
|
||||
}
|
||||
}
|
||||
|
||||
enum ErsteHilfeContent {
|
||||
static let topics: [ErsteHilfeTopic] = [
|
||||
ErsteHilfeTopic(
|
||||
id: "vergiftung",
|
||||
title: "Vergiftung",
|
||||
summary: "Schokolade, Trauben, Giftköder, Frostschutz, Medikamente.",
|
||||
icon: "exclamationmark.octagon.fill",
|
||||
tint: .red,
|
||||
sections: [
|
||||
.init(heading: "Sofortmaßnahmen", steps: [
|
||||
"Hund von der Giftquelle entfernen. Reste sichern (Foto/Verpackung).",
|
||||
"NICHT zum Erbrechen bringen, außer der Tierarzt sagt es ausdrücklich.",
|
||||
"Tierarzt oder Tierklinik anrufen — Art und Menge des Gifts angeben.",
|
||||
"Bei Bewusstlosigkeit: stabile Seitenlage, Atemwege freihalten."
|
||||
]),
|
||||
.init(heading: "Was du nicht tun solltest", steps: [
|
||||
"Kein Salzwasser, keine Milch, kein Öl geben — kann gefährlich sein.",
|
||||
"Nicht abwarten, ob Symptome verschwinden."
|
||||
])
|
||||
],
|
||||
warnings: [
|
||||
"Krämpfe, Erbrechen, Durchfall, Speicheln, Apathie",
|
||||
"Schaum vor dem Maul, Blutungen",
|
||||
"Verdacht auf Giftköder (Wurst mit Glas, Nägel, Tabletten)"
|
||||
]
|
||||
),
|
||||
ErsteHilfeTopic(
|
||||
id: "hitzschlag",
|
||||
title: "Hitzschlag",
|
||||
summary: "Hecheln, Taumeln, Bewusstseinsverlust durch Überhitzung.",
|
||||
icon: "thermometer.sun.fill",
|
||||
tint: .orange,
|
||||
sections: [
|
||||
.init(heading: "Sofort kühlen", steps: [
|
||||
"Hund in den Schatten oder kühlen Raum bringen.",
|
||||
"Pfoten, Innenschenkel und Bauch mit lauwarmem (nicht eiskaltem!) Wasser kühlen.",
|
||||
"Kleine Schlucke Wasser anbieten, nicht zwingen.",
|
||||
"Auf dem Weg zum Tierarzt weiterkühlen — feuchte Tücher auf den Körper."
|
||||
])
|
||||
],
|
||||
warnings: [
|
||||
"Körpertemperatur über 40 °C",
|
||||
"Verwirrung, Krämpfe, Erbrechen",
|
||||
"Zahnfleisch dunkelrot, blau oder bleich"
|
||||
]
|
||||
),
|
||||
ErsteHilfeTopic(
|
||||
id: "wunde",
|
||||
title: "Wunden & Blutungen",
|
||||
summary: "Schnitte, Bisse, Verletzungen an Pfoten und Körper.",
|
||||
icon: "bandage.fill",
|
||||
tint: .pink,
|
||||
sections: [
|
||||
.init(heading: "Bei starker Blutung", steps: [
|
||||
"Druckverband mit sauberem Tuch anlegen — fest, aber nicht abschnüren.",
|
||||
"Bei spritzendem Blut: oberhalb der Wunde mit den Fingern abdrücken.",
|
||||
"Pfote/Bein hochlagern, ruhig halten.",
|
||||
"Sofort zum Tierarzt."
|
||||
]),
|
||||
.init(heading: "Kleine Wunden", steps: [
|
||||
"Fremdkörper (Glas, Splitter) NICHT selbst rausziehen, wenn tief.",
|
||||
"Wunde mit klarem Wasser ausspülen.",
|
||||
"Trocken tupfen, locker abdecken — Hund vom Lecken abhalten."
|
||||
])
|
||||
],
|
||||
warnings: [
|
||||
"Stark blutende oder pumpende Wunde",
|
||||
"Fremdkörper steckt fest",
|
||||
"Tiefe Bisswunden (auch wenn klein) → Infektionsgefahr"
|
||||
]
|
||||
),
|
||||
ErsteHilfeTopic(
|
||||
id: "atemnot",
|
||||
title: "Atemnot & Bewusstlosigkeit",
|
||||
summary: "Erstickung, Würgen, Kollaps, Reanimation.",
|
||||
icon: "lungs.fill",
|
||||
tint: .blue,
|
||||
sections: [
|
||||
.init(heading: "Fremdkörper im Hals", steps: [
|
||||
"Maul vorsichtig öffnen, mit Taschenlampe schauen.",
|
||||
"Sichtbaren Fremdkörper mit den Fingern (nicht Pinzette!) lösen.",
|
||||
"Bei kleinem Hund: Kopf nach unten halten und zwischen die Schulterblätter klopfen.",
|
||||
"Bei großem Hund: Heimlich-Manöver — von hinten umfassen, ruckartig nach oben drücken."
|
||||
]),
|
||||
.init(heading: "Reanimation (CPR)", steps: [
|
||||
"Hund auf rechte Seite legen, Atemwege kontrollieren.",
|
||||
"Maul schließen, in die Nase atmen — Brustkorb soll sich heben.",
|
||||
"30 Herzdruckmassagen (auf Brustkorb-Höhe der Schulterblätter), dann 2 Beatmungen.",
|
||||
"Weiter bis Atmung einsetzt oder Tierarzt übernimmt."
|
||||
])
|
||||
],
|
||||
warnings: [
|
||||
"Bewusstlosigkeit, keine Atmung, kein Puls",
|
||||
"Blaue Schleimhäute",
|
||||
"Würgen ohne Erfolg über mehrere Minuten"
|
||||
]
|
||||
),
|
||||
ErsteHilfeTopic(
|
||||
id: "krampfanfall",
|
||||
title: "Krampfanfall",
|
||||
summary: "Epileptischer Anfall, Zittern, Bewusstseinsstörung.",
|
||||
icon: "waveform.path.ecg",
|
||||
tint: .purple,
|
||||
sections: [
|
||||
.init(heading: "Während des Anfalls", steps: [
|
||||
"Ruhe bewahren, Zeit messen (Dauer ist wichtig für den Tierarzt).",
|
||||
"Umgebung sichern — Möbel/Kanten wegräumen.",
|
||||
"NICHT festhalten, NICHTS ins Maul stecken.",
|
||||
"Licht, Geräusche und Reize reduzieren."
|
||||
]),
|
||||
.init(heading: "Nach dem Anfall", steps: [
|
||||
"Hund kann verwirrt, blind oder unruhig sein — Zeit geben.",
|
||||
"Wasser bereitstellen, in eine ruhige Ecke legen.",
|
||||
"Auch nach erstem Anfall zum Tierarzt — Ursache abklären."
|
||||
])
|
||||
],
|
||||
warnings: [
|
||||
"Anfall dauert länger als 5 Minuten (Notfall!)",
|
||||
"Mehrere Anfälle in Folge",
|
||||
"Erster Anfall überhaupt"
|
||||
]
|
||||
),
|
||||
ErsteHilfeTopic(
|
||||
id: "magendrehung",
|
||||
title: "Magendrehung",
|
||||
summary: "Akuter Notfall, vor allem bei großen Rassen.",
|
||||
icon: "stomach.fill",
|
||||
tint: .red,
|
||||
sections: [
|
||||
.init(heading: "Erkennen", steps: [
|
||||
"Aufgeblähter, harter Bauch.",
|
||||
"Erfolgloses Würgen ohne Erbrechen.",
|
||||
"Unruhe, Speicheln, Atemnot.",
|
||||
"Häufig nach dem Fressen / Trinken großer Mengen."
|
||||
]),
|
||||
.init(heading: "Sofort handeln", steps: [
|
||||
"JEDE Minute zählt — direkt zum Notfall-Tierarzt fahren.",
|
||||
"Beim Transport ruhig halten, nicht füttern, nicht tränken.",
|
||||
"Vorher anrufen, damit OP-Team bereitsteht."
|
||||
])
|
||||
],
|
||||
warnings: [
|
||||
"Symptome wie oben → IMMER Notfall",
|
||||
"Ohne OP innerhalb weniger Stunden tödlich"
|
||||
]
|
||||
)
|
||||
]
|
||||
}
|
||||
207
BanYaroGo/Views/GassiZeitenView.swift
Normal file
207
BanYaroGo/Views/GassiZeitenView.swift
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
import SwiftUI
|
||||
import UserNotifications
|
||||
|
||||
struct GassiZeitenView: View {
|
||||
@State private var items: [GassiZeit] = []
|
||||
@State private var isLoading = false
|
||||
@State private var errorMessage: String?
|
||||
@State private var showAdd = false
|
||||
|
||||
private let weekdayLabels: [String: String] = [
|
||||
"mo": "Mo", "di": "Di", "mi": "Mi", "do": "Do",
|
||||
"fr": "Fr", "sa": "Sa", "so": "So"
|
||||
]
|
||||
private let weekdayOrder = ["mo", "di", "mi", "do", "fr", "sa", "so"]
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.navigationTitle("Gassi-Zeiten")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button { showAdd = true } label: { Image(systemName: "plus") }
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showAdd) {
|
||||
AddGassiZeitSheet { await load() }
|
||||
}
|
||||
.task {
|
||||
_ = try? await UNUserNotificationCenter.current()
|
||||
.requestAuthorization(options: [.alert, .sound, .badge])
|
||||
await load()
|
||||
}
|
||||
.refreshable { await load() }
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
if isLoading && items.isEmpty {
|
||||
ProgressView()
|
||||
} else if let errorMessage, items.isEmpty {
|
||||
ContentUnavailableView("Konnte nicht laden", systemImage: "wifi.slash", description: Text(errorMessage))
|
||||
} else if items.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"Noch keine Gassi-Zeiten",
|
||||
systemImage: "alarm",
|
||||
description: Text("Tippe oben rechts auf +, um regelmäßige Erinnerungen zu setzen.")
|
||||
)
|
||||
} else {
|
||||
List {
|
||||
Section {
|
||||
ForEach(items) { z in
|
||||
row(z)
|
||||
}
|
||||
} footer: {
|
||||
Text("Erinnerungen kommen lokal vom iPhone — auch ohne Internet.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func row(_ z: GassiZeit) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "alarm.fill")
|
||||
.foregroundStyle(z.aktiv == 0 ? Color.secondary : Color.accentColor)
|
||||
.frame(width: 28)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 8) {
|
||||
Text(z.uhrzeit).font(.headline.monospacedDigit())
|
||||
Spacer()
|
||||
if let dog = z.dogName {
|
||||
Text(dog).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
HStack(spacing: 6) {
|
||||
ForEach(weekdayOrder, id: \.self) { wd in
|
||||
let active = z.wochentage.contains(wd)
|
||||
Text(weekdayLabels[wd] ?? wd)
|
||||
.font(.caption2.bold())
|
||||
.foregroundStyle(active ? .white : .secondary)
|
||||
.frame(width: 22, height: 22)
|
||||
.background(active ? Color.accentColor : Color.secondary.opacity(0.15), in: Circle())
|
||||
}
|
||||
}
|
||||
if let n = z.notiz, !n.isEmpty {
|
||||
Text(n).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
private func load() async {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
defer { isLoading = false }
|
||||
do {
|
||||
items = try await APIClient.shared.get("/api/gassi-zeiten?nur_eigene=true")
|
||||
// Re-sync local notifications to reflect server state.
|
||||
for z in items where z.isMine ?? true {
|
||||
await GassiZeitenScheduler.reschedule(z)
|
||||
}
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct AddGassiZeitSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
let onSaved: () async -> Void
|
||||
|
||||
@State private var time = Calendar.current.date(bySettingHour: 8, minute: 0, second: 0, of: .now) ?? .now
|
||||
@State private var selectedDays: Set<String> = ["mo", "di", "mi", "do", "fr"]
|
||||
@State private var notiz = ""
|
||||
@State private var dogs: [Dog] = []
|
||||
@State private var dogId: Int?
|
||||
@State private var isSaving = false
|
||||
@State private var errorMessage: String?
|
||||
|
||||
private let weekdayOrder = ["mo", "di", "mi", "do", "fr", "sa", "so"]
|
||||
private let weekdayLabels: [String: String] = [
|
||||
"mo": "Mo", "di": "Di", "mi": "Mi", "do": "Do",
|
||||
"fr": "Fr", "sa": "Sa", "so": "So"
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("Uhrzeit") {
|
||||
DatePicker("Uhrzeit", selection: $time, displayedComponents: .hourAndMinute)
|
||||
.environment(\.locale, Locale(identifier: "de_DE"))
|
||||
}
|
||||
Section("Wochentage") {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(weekdayOrder, id: \.self) { wd in
|
||||
let active = selectedDays.contains(wd)
|
||||
Button {
|
||||
if active { selectedDays.remove(wd) } else { selectedDays.insert(wd) }
|
||||
} label: {
|
||||
Text(weekdayLabels[wd] ?? wd)
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(active ? .white : .primary)
|
||||
.frame(width: 34, height: 34)
|
||||
.background(active ? Color.accentColor : Color.secondary.opacity(0.15), in: Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
Section("Hund (optional)") {
|
||||
Picker("Hund", selection: $dogId) {
|
||||
Text("Ohne Hund").tag(Int?.none)
|
||||
ForEach(dogs) { d in Text(d.name).tag(Int?.some(d.id)) }
|
||||
}
|
||||
}
|
||||
Section("Notiz (optional)") {
|
||||
TextField("Notiz", text: $notiz, axis: .vertical)
|
||||
.lineLimit(2...4)
|
||||
}
|
||||
if let errorMessage {
|
||||
Section { Text(errorMessage).font(.footnote).foregroundStyle(.red) }
|
||||
}
|
||||
}
|
||||
.navigationTitle("Neue Gassi-Zeit")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Abbrechen") { dismiss() }.disabled(isSaving)
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
if isSaving { ProgressView() }
|
||||
else { Button("Sichern") { Task { await save() } }.disabled(selectedDays.isEmpty) }
|
||||
}
|
||||
}
|
||||
.task { dogs = (try? await APIClient.shared.get("/api/dogs")) ?? [] }
|
||||
}
|
||||
}
|
||||
|
||||
private func save() async {
|
||||
isSaving = true
|
||||
errorMessage = nil
|
||||
defer { isSaving = false }
|
||||
|
||||
let f = DateFormatter()
|
||||
f.dateFormat = "HH:mm"
|
||||
let body = GassiZeitCreateBody(
|
||||
dogId: dogId,
|
||||
wochentage: weekdayOrder.filter { selectedDays.contains($0) },
|
||||
uhrzeit: f.string(from: time),
|
||||
ortName: nil,
|
||||
lat: nil,
|
||||
lon: nil,
|
||||
radiusM: 500,
|
||||
notiz: notiz.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : notiz
|
||||
)
|
||||
do {
|
||||
let created: GassiZeit = try await APIClient.shared.post("/api/gassi-zeiten", body: body)
|
||||
await GassiZeitenScheduler.reschedule(created)
|
||||
await onSaved()
|
||||
dismiss()
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -73,11 +73,16 @@ struct LoginView: View {
|
|||
VStack(alignment: .leading, spacing: 14) {
|
||||
Divider()
|
||||
feature(icon: "map.fill", title: "Gassi-Touren aufzeichnen", subtitle: "GPS-Tracking auch im Hintergrund — mit Pause, Live Activity und HealthKit-Sync.")
|
||||
feature(icon: "alarm.fill", title: "Gassi-Zeiten", subtitle: "Tägliche Erinnerungen, damit keine Runde vergessen wird.")
|
||||
feature(icon: "person.2.fill", title: "Hunde-Community", subtitle: "Gassi-Treffen, Tierärzte und Orte in deiner Nähe.")
|
||||
feature(icon: "book.fill", title: "Tagebuch & Impfpass", subtitle: "Alles rund um deinen Hund an einem Ort.")
|
||||
feature(icon: "rosette", title: "Verifizierte Züchter", subtitle: "Züchter-Profile, aktuelle Würfe und Welpen-Vermittlung — kein Hinterhof.")
|
||||
feature(icon: "exclamationmark.shield.fill", title: "Giftköder-Alarm", subtitle: "Warnungen aus deiner Region direkt aufs iPhone.")
|
||||
feature(icon: "house.fill", title: "Hundesitting", subtitle: "Sitter und Sitter-Suche in deiner Nähe — mit Bewertungen.")
|
||||
feature(icon: "magnifyingglass.circle.fill", title: "Verlorene Hunde", subtitle: "Vermisstmeldungen in deinem Umkreis sehen oder selbst melden.")
|
||||
feature(icon: "eurosign.circle.fill", title: "Ausgaben tracken", subtitle: "Futter, Tierarzt, Versicherung — alle Hundekosten an einem Ort.")
|
||||
feature(icon: "cross.case.fill", title: "Erste Hilfe", subtitle: "Notfall-Anleitung für Vergiftung, Hitzschlag, Wunden — komplett offline.")
|
||||
feature(icon: "cloud.sun.fill", title: "Wetter für Hunde", subtitle: "Vorhersage mit Hitze- und Kältewarnung — was für deinen Hund passt.")
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.bottom, 18)
|
||||
|
|
|
|||
|
|
@ -27,6 +27,39 @@ struct SettingsView: View {
|
|||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
Section("Hund & Alltag") {
|
||||
NavigationLink {
|
||||
ErsteHilfeView()
|
||||
} label: {
|
||||
Label("Erste Hilfe", systemImage: "cross.case.fill")
|
||||
}
|
||||
NavigationLink {
|
||||
WetterView()
|
||||
} label: {
|
||||
Label("Wetter", systemImage: "cloud.sun.fill")
|
||||
}
|
||||
NavigationLink {
|
||||
GassiZeitenView()
|
||||
} label: {
|
||||
Label("Gassi-Zeiten", systemImage: "alarm.fill")
|
||||
}
|
||||
NavigationLink {
|
||||
GiftkoederView()
|
||||
} label: {
|
||||
Label("Giftköder", systemImage: "exclamationmark.octagon.fill")
|
||||
}
|
||||
NavigationLink {
|
||||
VerloreneHundeView()
|
||||
} label: {
|
||||
Label("Verlorene Hunde", systemImage: "magnifyingglass.circle.fill")
|
||||
}
|
||||
NavigationLink {
|
||||
AusgabenView()
|
||||
} label: {
|
||||
Label("Ausgaben", systemImage: "eurosign.circle.fill")
|
||||
}
|
||||
}
|
||||
|
||||
Section("Account") {
|
||||
LabeledContent("Rolle", value: rolleLabel)
|
||||
if auth.profile?.isFounderFlag == true {
|
||||
|
|
|
|||
176
BanYaroGo/Views/VerloreneHundeView.swift
Normal file
176
BanYaroGo/Views/VerloreneHundeView.swift
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
172
BanYaroGo/Views/WetterView.swift
Normal file
172
BanYaroGo/Views/WetterView.swift
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import SwiftUI
|
||||
import CoreLocation
|
||||
|
||||
struct WetterView: View {
|
||||
@State private var location = OneShotLocation()
|
||||
@State private var forecast: WeatherForecast?
|
||||
@State private var isLoading = false
|
||||
@State private var errorMessage: String?
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let forecast {
|
||||
List {
|
||||
Section {
|
||||
ForEach(Array(forecast.days.prefix(7))) { day in
|
||||
WeatherDayRow(day: day)
|
||||
}
|
||||
} footer: {
|
||||
Text("Vorhersage von Open-Meteo. Hunde-Tipps basieren auf maximaler Tagestemperatur und Asphalt-Hitze.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
} else if isLoading {
|
||||
ProgressView("Lade Wetter…")
|
||||
} else if let errorMessage {
|
||||
ContentUnavailableView(
|
||||
"Wetter konnte nicht geladen werden",
|
||||
systemImage: "cloud.slash",
|
||||
description: Text(errorMessage)
|
||||
)
|
||||
} else if location.error != nil {
|
||||
ContentUnavailableView(
|
||||
"Kein Standort",
|
||||
systemImage: "location.slash",
|
||||
description: Text(location.error ?? "Bitte Standort erlauben.")
|
||||
)
|
||||
} else {
|
||||
ProgressView("Hole Standort…")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Wetter")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task {
|
||||
location.request()
|
||||
}
|
||||
.onChange(of: location.coordinate?.latitude) { _, _ in
|
||||
Task { await load() }
|
||||
}
|
||||
.refreshable { await load() }
|
||||
}
|
||||
|
||||
private func load() async {
|
||||
guard let coord = location.coordinate else { return }
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
defer { isLoading = false }
|
||||
do {
|
||||
forecast = try await APIClient.shared.get(
|
||||
"/api/weather/forecast?lat=\(coord.latitude)&lon=\(coord.longitude)"
|
||||
)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct WeatherDayRow: View {
|
||||
let day: WeatherDay
|
||||
|
||||
private var dateLabel: String {
|
||||
if day.date == today { return "Heute" }
|
||||
if day.date == tomorrow { return "Morgen" }
|
||||
return day.wday ?? day.date
|
||||
}
|
||||
|
||||
private var today: String {
|
||||
let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"
|
||||
return f.string(from: .now)
|
||||
}
|
||||
private var tomorrow: String {
|
||||
let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"
|
||||
let d = Calendar.current.date(byAdding: .day, value: 1, to: .now) ?? .now
|
||||
return f.string(from: d)
|
||||
}
|
||||
|
||||
private var weatherSymbol: String {
|
||||
switch day.weathercode ?? 0 {
|
||||
case 0: return "sun.max.fill"
|
||||
case 1, 2: return "cloud.sun.fill"
|
||||
case 3: return "cloud.fill"
|
||||
case 45, 48: return "cloud.fog.fill"
|
||||
case 51...57: return "cloud.drizzle.fill"
|
||||
case 61...67: return "cloud.rain.fill"
|
||||
case 71...77: return "cloud.snow.fill"
|
||||
case 80...82: return "cloud.heavyrain.fill"
|
||||
case 95...99: return "cloud.bolt.rain.fill"
|
||||
default: return "cloud"
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: weatherSymbol)
|
||||
.font(.title2)
|
||||
.foregroundStyle(Color.accentColor)
|
||||
.frame(width: 36)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(dateLabel).font(.headline)
|
||||
if let desc = day.desc { Text(desc).font(.caption).foregroundStyle(.secondary) }
|
||||
}
|
||||
Spacer()
|
||||
tempColumn
|
||||
}
|
||||
|
||||
if !tips.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
ForEach(Array(tips.enumerated()), id: \.offset) { _, tip in
|
||||
Label(tip.text, systemImage: tip.icon)
|
||||
.font(.caption)
|
||||
.foregroundStyle(tip.color)
|
||||
}
|
||||
}
|
||||
.padding(.leading, 48)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
private var tempColumn: some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||
if let max = day.tempMax {
|
||||
Text("\(Int(max.rounded()))°").font(.headline.monospacedDigit())
|
||||
}
|
||||
if let min = day.tempMin {
|
||||
Text("\(Int(min.rounded()))°")
|
||||
.font(.subheadline.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct Tip {
|
||||
let text: String
|
||||
let icon: String
|
||||
let color: Color
|
||||
}
|
||||
|
||||
private var tips: [Tip] {
|
||||
var result: [Tip] = []
|
||||
if let max = day.tempMax {
|
||||
if max >= 30 {
|
||||
result.append(Tip(text: "Extreme Hitze — Gassi nur früh morgens/abends", icon: "sun.dust.fill", color: .red))
|
||||
} else if max >= 25 {
|
||||
result.append(Tip(text: "Warm — Pfoten auf Asphalt prüfen", icon: "thermometer.sun", color: .orange))
|
||||
} else if max <= 0 {
|
||||
result.append(Tip(text: "Frost — Pfoten nach Streusalz abwischen", icon: "snowflake", color: .blue))
|
||||
}
|
||||
}
|
||||
if let asphalt = day.asphaltTemp, asphalt >= 50 {
|
||||
result.append(Tip(text: "Asphalt ~\(Int(asphalt.rounded()))°C — verbrennungsgefahr", icon: "flame.fill", color: .red))
|
||||
}
|
||||
if let zecken = day.zecken, zecken == "hoch" {
|
||||
result.append(Tip(text: "Hohe Zecken-Gefahr", icon: "ant.fill", color: .orange))
|
||||
}
|
||||
if let pp = day.precipProb, pp >= 70 {
|
||||
result.append(Tip(text: "Regen wahrscheinlich (\(pp) %)", icon: "umbrella.fill", color: .blue))
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue