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.
228 lines
7.8 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|