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
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
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue