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