banyaro-ios/BanYaroGo/Views/AusgabenView.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

228 lines
7.8 KiB
Swift

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
}
}
}