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