Gassi-Wetter mit WeatherKit + banyaro-Logik
WeatherKit als Datenquelle (statt OpenMeteo-Proxy via banyaro-Backend): - BanYaroGo.entitlements: com.apple.developer.weatherkit - WetterView komplett neu mit WeatherService.shared.weather(for:) - DayWeather.symbolName als SF-Symbol direkt, kein WMO-Mapping nötig GassiWetter-Logik (1:1-Port aus banyaro PWA wetter.js): - gassiScore(...) 1-10 mit Temp/Regen/Wind/Asphalt/Gewitter - asphaltTemp(airMax, uvMax) — gleiche Formel mit t_factor und UV-Bonus - asphaltLevel safe/warm/hot/danger mit Advice-Texten - schnueffelIndex aus Feuchte (precipProb-derived) und Temperatur - tickRisk March-Oktober, Schwellen 7/12/20°C - pawColdProtection bei tempMin <= 0 UI: - Horizontaler Tag-Picker (Heute/Morgen + EEE) mit Mini-Stats - Großer Gassi-Score-Badge in Empfehlungs-Farbe (grün/amber/rot) - Stats-Grid 2x2: Niederschlag, Wind, UV, Asphalt - Hunde-Hinweise als farbige Boxen (Asphalt, Pfoten, Gewitter, Zecken) - Schnüffel-Index als kompakte Karte mit Emoji Color(hex:)-Extension für die HEX-Werte aus dem PWA übernommen.
This commit is contained in:
parent
0867a2171f
commit
357c57e880
4 changed files with 502 additions and 125 deletions
|
|
@ -6,5 +6,7 @@
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.developer.healthkit.access</key>
|
<key>com.apple.developer.healthkit.access</key>
|
||||||
<array/>
|
<array/>
|
||||||
|
<key>com.apple.developer.weatherkit</key>
|
||||||
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|
|
||||||
20
BanYaroGo/Support/ColorHex.swift
Normal file
20
BanYaroGo/Support/ColorHex.swift
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
extension Color {
|
||||||
|
/// Creates a Color from a 6-digit hex string (e.g. "10B981" or "#10B981").
|
||||||
|
/// Falls back to system gray on bad input.
|
||||||
|
init(hex: String) {
|
||||||
|
let cleaned = hex.trimmingCharacters(in: CharacterSet(charactersIn: "#"))
|
||||||
|
var value: UInt64 = 0
|
||||||
|
Scanner(string: cleaned).scanHexInt64(&value)
|
||||||
|
|
||||||
|
guard cleaned.count == 6 else {
|
||||||
|
self.init(.sRGB, red: 0.5, green: 0.5, blue: 0.5)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let r = Double((value >> 16) & 0xFF) / 255
|
||||||
|
let g = Double((value >> 8) & 0xFF) / 255
|
||||||
|
let b = Double(value & 0xFF) / 255
|
||||||
|
self.init(.sRGB, red: r, green: g, blue: b)
|
||||||
|
}
|
||||||
|
}
|
||||||
182
BanYaroGo/Support/GassiWetter.swift
Normal file
182
BanYaroGo/Support/GassiWetter.swift
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Gassi-Wetter-Bewertung — 1:1-Port der Logik aus banyaro PWA (wetter.js).
|
||||||
|
/// Asphalt-Formel, Gassi-Score-Komposition, Schnüffel-Index und Zecken-
|
||||||
|
/// Heuristik identisch, damit PWA und Go App dieselbe Empfehlung geben.
|
||||||
|
enum GassiWetter {
|
||||||
|
|
||||||
|
// MARK: - Asphalt-Temperatur (aus Lufttemp + UV-Index)
|
||||||
|
|
||||||
|
static func asphaltTemp(airMax: Double, uvMax: Double) -> Double {
|
||||||
|
// UV-Bonus skaliert mit Temperatur: unter 5°C kaum Aufheizung, ab 30°C voll
|
||||||
|
let tFactor = max(0.0, min(1.0, (airMax - 5) / 25))
|
||||||
|
let bonus = min(uvMax * 3.0 * tFactor, 30.0)
|
||||||
|
return (airMax + bonus).rounded(toPlaces: 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AsphaltLevel {
|
||||||
|
case safe, warm, hot, danger
|
||||||
|
|
||||||
|
var label: String {
|
||||||
|
switch self {
|
||||||
|
case .safe: return "Unbedenklich"
|
||||||
|
case .warm: return "Warm — kurze Runden ok"
|
||||||
|
case .hot: return "Heiß — Pfoten verbrennen"
|
||||||
|
case .danger: return "Gefährlich — gar nicht laufen"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var advice: String? {
|
||||||
|
switch self {
|
||||||
|
case .safe: return nil
|
||||||
|
case .warm: return "Schatten suchen, Pausen einlegen."
|
||||||
|
case .hot: return "Gras oder Schatten bevorzugen — Asphalt vermeiden."
|
||||||
|
case .danger: return "Nur Morgens/Abends rausgehen, mittags drinnen."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func asphaltLevel(for asphalt: Double) -> AsphaltLevel {
|
||||||
|
switch asphalt {
|
||||||
|
case ..<30: return .safe
|
||||||
|
case ..<40: return .warm
|
||||||
|
case ..<55: return .hot
|
||||||
|
default: return .danger
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Gassi-Score (1–10)
|
||||||
|
|
||||||
|
static func gassiScore(
|
||||||
|
tempMax: Double,
|
||||||
|
precipProb: Int,
|
||||||
|
windKmh: Double,
|
||||||
|
asphalt: Double,
|
||||||
|
thunderstorm: Bool
|
||||||
|
) -> Int {
|
||||||
|
var score = 10
|
||||||
|
|
||||||
|
// Temperatur (ideal 10–20°C)
|
||||||
|
if tempMax > 30 { score -= 3 }
|
||||||
|
else if tempMax > 25 { score -= 1 }
|
||||||
|
else if tempMax < 0 { score -= 3 }
|
||||||
|
else if tempMax < 5 { score -= 1 }
|
||||||
|
|
||||||
|
// Regen
|
||||||
|
if precipProb > 70 { score -= 3 }
|
||||||
|
else if precipProb > 40 { score -= 2 }
|
||||||
|
else if precipProb > 20 { score -= 1 }
|
||||||
|
|
||||||
|
// Wind
|
||||||
|
if windKmh > 60 { score -= 2 }
|
||||||
|
else if windKmh > 40 { score -= 1 }
|
||||||
|
|
||||||
|
// Asphalt
|
||||||
|
if asphalt > 55 { score -= 2 }
|
||||||
|
else if asphalt > 45 { score -= 1 }
|
||||||
|
|
||||||
|
// Gewitter
|
||||||
|
if thunderstorm { score -= 3 }
|
||||||
|
|
||||||
|
return max(1, min(10, score))
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GassiScoreBadge {
|
||||||
|
let score: Int
|
||||||
|
let label: String
|
||||||
|
let colorHex: String
|
||||||
|
|
||||||
|
init(score: Int) {
|
||||||
|
self.score = score
|
||||||
|
if score >= 8 {
|
||||||
|
label = "Toller Gassi-Tag!"
|
||||||
|
colorHex = "10B981"
|
||||||
|
} else if score >= 5 {
|
||||||
|
label = "Geht so"
|
||||||
|
colorHex = "F59E0B"
|
||||||
|
} else {
|
||||||
|
label = "Lieber drinbleiben"
|
||||||
|
colorHex = "EF4444"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Schnüffel-Index
|
||||||
|
|
||||||
|
struct SchnueffelIndex {
|
||||||
|
let label: String
|
||||||
|
let emoji: String
|
||||||
|
let colorHex: String
|
||||||
|
}
|
||||||
|
|
||||||
|
static func schnueffelIndex(tempMax: Double, precipProb: Int) -> SchnueffelIndex {
|
||||||
|
let feucht: Feuchte = precipProb > 60 ? .feucht
|
||||||
|
: precipProb > 30 ? .leicht
|
||||||
|
: .trocken
|
||||||
|
|
||||||
|
if feucht == .feucht, tempMax >= 10, tempMax <= 18 {
|
||||||
|
return SchnueffelIndex(label: "Exzellent", emoji: "👃", colorHex: "10B981")
|
||||||
|
}
|
||||||
|
if feucht == .feucht, tempMax > 10, tempMax <= 22 {
|
||||||
|
return SchnueffelIndex(label: "Sehr gut", emoji: "👃", colorHex: "34D399")
|
||||||
|
}
|
||||||
|
if tempMax < 5 {
|
||||||
|
return SchnueffelIndex(label: "Gut (kalte Luft trägt Gerüche)", emoji: "🌬️", colorHex: "60A5FA")
|
||||||
|
}
|
||||||
|
if feucht == .leicht, tempMax >= 10, tempMax <= 22 {
|
||||||
|
return SchnueffelIndex(label: "Gut", emoji: "👃", colorHex: "4CAF50")
|
||||||
|
}
|
||||||
|
if feucht == .trocken, tempMax > 25 {
|
||||||
|
return SchnueffelIndex(label: "Schwach", emoji: "💨", colorHex: "94A3B8")
|
||||||
|
}
|
||||||
|
return SchnueffelIndex(label: "Mittel", emoji: "🌫", colorHex: "F59E0B")
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum Feuchte { case feucht, leicht, trocken }
|
||||||
|
|
||||||
|
// MARK: - Zecken-Risiko
|
||||||
|
|
||||||
|
enum TickRisk {
|
||||||
|
case none, low, medium, high
|
||||||
|
|
||||||
|
var label: String {
|
||||||
|
switch self {
|
||||||
|
case .none: return "Keine"
|
||||||
|
case .low: return "Niedrig"
|
||||||
|
case .medium: return "Mittel"
|
||||||
|
case .high: return "Hoch"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var colorHex: String {
|
||||||
|
switch self {
|
||||||
|
case .none: return "94A3B8"
|
||||||
|
case .low: return "10B981"
|
||||||
|
case .medium: return "F59E0B"
|
||||||
|
case .high: return "EF4444"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func tickRisk(tempMax: Double, month: Int) -> TickRisk {
|
||||||
|
// Wie in banyaro: nur März-Oktober relevant
|
||||||
|
guard (3...10).contains(month) else { return .none }
|
||||||
|
if tempMax <= 7 { return .none }
|
||||||
|
if tempMax > 20 { return .high }
|
||||||
|
if tempMax > 12 { return .medium }
|
||||||
|
return .low
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Pfoten-Kälteschutz
|
||||||
|
|
||||||
|
static func pawColdProtection(tempMin: Double) -> Bool {
|
||||||
|
tempMin <= 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension Double {
|
||||||
|
func rounded(toPlaces places: Int) -> Double {
|
||||||
|
let multiplier = pow(10.0, Double(places))
|
||||||
|
return (self * multiplier).rounded() / multiplier
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,31 +1,23 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import WeatherKit
|
||||||
import CoreLocation
|
import CoreLocation
|
||||||
|
|
||||||
struct WetterView: View {
|
struct WetterView: View {
|
||||||
@State private var location = OneShotLocation()
|
@State private var location = OneShotLocation()
|
||||||
@State private var forecast: WeatherForecast?
|
@State private var weather: Weather?
|
||||||
@State private var isLoading = false
|
@State private var isLoading = false
|
||||||
@State private var errorMessage: String?
|
@State private var errorMessage: String?
|
||||||
|
@State private var selectedDayIndex = 0
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
if let forecast {
|
if let weather {
|
||||||
List {
|
content(weather: weather)
|
||||||
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 {
|
} else if isLoading {
|
||||||
ProgressView("Lade Wetter…")
|
ProgressView("Lade Wetter…")
|
||||||
} else if let errorMessage {
|
} else if let errorMessage {
|
||||||
ContentUnavailableView(
|
ContentUnavailableView(
|
||||||
"Wetter konnte nicht geladen werden",
|
"Konnte nicht laden",
|
||||||
systemImage: "cloud.slash",
|
systemImage: "cloud.slash",
|
||||||
description: Text(errorMessage)
|
description: Text(errorMessage)
|
||||||
)
|
)
|
||||||
|
|
@ -33,140 +25,321 @@ struct WetterView: View {
|
||||||
ContentUnavailableView(
|
ContentUnavailableView(
|
||||||
"Kein Standort",
|
"Kein Standort",
|
||||||
systemImage: "location.slash",
|
systemImage: "location.slash",
|
||||||
description: Text(location.error ?? "Bitte Standort erlauben.")
|
description: Text(location.error ?? "")
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
ProgressView("Hole Standort…")
|
ProgressView("Hole Standort…")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Wetter")
|
.navigationTitle("Gassi-Wetter")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.task {
|
.task { location.request() }
|
||||||
location.request()
|
|
||||||
}
|
|
||||||
.onChange(of: location.coordinate?.latitude) { _, _ in
|
.onChange(of: location.coordinate?.latitude) { _, _ in
|
||||||
Task { await load() }
|
Task { await loadWeather() }
|
||||||
}
|
}
|
||||||
.refreshable { await load() }
|
.refreshable { await loadWeather() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private func load() async {
|
private func content(weather: Weather) -> some View {
|
||||||
|
let days = Array(weather.dailyForecast.prefix(7))
|
||||||
|
let safeIndex = max(0, min(selectedDayIndex, days.count - 1))
|
||||||
|
|
||||||
|
return ScrollView {
|
||||||
|
VStack(spacing: 14) {
|
||||||
|
if !days.isEmpty {
|
||||||
|
dayPicker(days: days)
|
||||||
|
let day = days[safeIndex]
|
||||||
|
let metrics = DayMetrics(day: day)
|
||||||
|
|
||||||
|
gassiScoreBadge(metrics: metrics)
|
||||||
|
summaryCard(day: day, metrics: metrics)
|
||||||
|
statsGrid(metrics: metrics)
|
||||||
|
hundeHinweise(day: day, metrics: metrics)
|
||||||
|
schnueffelCard(metrics: metrics)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("Wetterdaten von Apple WeatherKit. Gassi-Score, Asphalt-Temperatur und Hunde-Hinweise werden lokal berechnet — identische Formeln wie banyaro.app.")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Day picker
|
||||||
|
|
||||||
|
private func dayPicker(days: [DayWeather]) -> some View {
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ForEach(Array(days.enumerated()), id: \.offset) { i, day in
|
||||||
|
Button {
|
||||||
|
selectedDayIndex = i
|
||||||
|
} label: {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Text(dayLabel(day.date, index: i))
|
||||||
|
.font(.caption.bold())
|
||||||
|
Image(systemName: day.symbolName)
|
||||||
|
.symbolRenderingMode(.multicolor)
|
||||||
|
.font(.title3)
|
||||||
|
Text("\(Int(day.highTemperature.converted(to: .celsius).value.rounded()))°")
|
||||||
|
.font(.subheadline.bold().monospacedDigit())
|
||||||
|
}
|
||||||
|
.frame(width: 56)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(
|
||||||
|
i == selectedDayIndex ? Color.accentColor : Color.secondary.opacity(0.12),
|
||||||
|
in: RoundedRectangle(cornerRadius: 12)
|
||||||
|
)
|
||||||
|
.foregroundStyle(i == selectedDayIndex ? .white : .primary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func dayLabel(_ date: Date, index: Int) -> String {
|
||||||
|
if index == 0 { return "Heute" }
|
||||||
|
if index == 1 { return "Morgen" }
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.locale = Locale(identifier: "de_DE")
|
||||||
|
f.dateFormat = "EEE"
|
||||||
|
return f.string(from: date)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Gassi-Score Badge
|
||||||
|
|
||||||
|
private func gassiScoreBadge(metrics: DayMetrics) -> some View {
|
||||||
|
let badge = GassiWetter.GassiScoreBadge(score: metrics.gassiScore)
|
||||||
|
let color = Color(hex: badge.colorHex)
|
||||||
|
return HStack(spacing: 14) {
|
||||||
|
Text("🐾").font(.system(size: 32))
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Gassi-Score").font(.caption.bold()).foregroundStyle(.secondary)
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||||
|
Text("\(badge.score)").font(.system(size: 40, weight: .heavy, design: .rounded))
|
||||||
|
Text("/ 10").font(.title3.bold()).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Text(badge.label).font(.subheadline.bold()).foregroundStyle(color)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(color.opacity(0.15), in: RoundedRectangle(cornerRadius: 16))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 16)
|
||||||
|
.stroke(color.opacity(0.5), lineWidth: 1.5)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Summary card
|
||||||
|
|
||||||
|
private func summaryCard(day: DayWeather, metrics: DayMetrics) -> some View {
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
Image(systemName: day.symbolName)
|
||||||
|
.symbolRenderingMode(.multicolor)
|
||||||
|
.font(.system(size: 56))
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(day.condition.description)
|
||||||
|
.font(.headline)
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text("\(Int(metrics.tempMax.rounded()))°").font(.title3.bold().monospacedDigit())
|
||||||
|
Text("/ \(Int(metrics.tempMin.rounded()))°")
|
||||||
|
.font(.subheadline.monospacedDigit())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(.background.secondary, in: RoundedRectangle(cornerRadius: 16))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Stats grid
|
||||||
|
|
||||||
|
private func statsGrid(metrics: DayMetrics) -> some View {
|
||||||
|
let cols = [GridItem(.flexible()), GridItem(.flexible())]
|
||||||
|
return LazyVGrid(columns: cols, spacing: 10) {
|
||||||
|
statCell(icon: "umbrella.fill", color: .blue,
|
||||||
|
value: "\(metrics.precipPct) %", label: "Niederschlag")
|
||||||
|
statCell(icon: "wind", color: .gray,
|
||||||
|
value: "\(Int(metrics.windKmh.rounded())) km/h", label: "Wind")
|
||||||
|
statCell(icon: "sun.max.fill", color: .orange,
|
||||||
|
value: "\(metrics.uvIndex)", label: "UV-Index")
|
||||||
|
statCell(icon: "thermometer.sun.fill", color: .red,
|
||||||
|
value: "\(Int(metrics.asphalt.rounded()))°", label: "Asphalt")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func statCell(icon: String, color: Color, value: String, label: String) -> some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.foregroundStyle(color)
|
||||||
|
.frame(width: 28)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(value).font(.subheadline.bold().monospacedDigit())
|
||||||
|
Text(label).font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.background(.background.secondary, in: RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Hunde-Hinweise
|
||||||
|
|
||||||
|
private func hundeHinweise(day: DayWeather, metrics: DayMetrics) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Label("Hunde-Hinweise", systemImage: "pawprint.fill")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(Color.accentColor)
|
||||||
|
|
||||||
|
// Asphalt
|
||||||
|
let level = GassiWetter.asphaltLevel(for: metrics.asphalt)
|
||||||
|
hintBox(
|
||||||
|
icon: "thermometer.sun.fill",
|
||||||
|
color: asphaltColor(for: level),
|
||||||
|
title: "Asphalt ~\(Int(metrics.asphalt.rounded()))°C — \(level.label)",
|
||||||
|
detail: level.advice
|
||||||
|
)
|
||||||
|
|
||||||
|
// Pfoten-Kälteschutz
|
||||||
|
if GassiWetter.pawColdProtection(tempMin: metrics.tempMin) {
|
||||||
|
hintBox(
|
||||||
|
icon: "snowflake",
|
||||||
|
color: .blue,
|
||||||
|
title: "Pfoten-Kälteschutz",
|
||||||
|
detail: "Eis und Streusalz reizen die Pfoten. Pfotenpflege empfohlen, ggf. Schuhe."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gewitter
|
||||||
|
if metrics.thunderstorm {
|
||||||
|
hintBox(
|
||||||
|
icon: "cloud.bolt.fill",
|
||||||
|
color: .purple,
|
||||||
|
title: "Gewitter erwartet",
|
||||||
|
detail: "Hunde reagieren oft sensibel. Sichere, ruhige Umgebung schaffen."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zecken
|
||||||
|
let month = Calendar.current.component(.month, from: day.date)
|
||||||
|
let tick = GassiWetter.tickRisk(tempMax: metrics.tempMax, month: month)
|
||||||
|
if tick != .none {
|
||||||
|
hintBox(
|
||||||
|
icon: "ant.fill",
|
||||||
|
color: Color(hex: tick.colorHex),
|
||||||
|
title: "Zecken-Risiko: \(tick.label)",
|
||||||
|
detail: tick == .high ? "Nach jedem Spaziergang absuchen, Schutzmittel verwenden." : nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func hintBox(icon: String, color: Color, title: String, detail: String?) -> some View {
|
||||||
|
HStack(alignment: .top, spacing: 10) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.foregroundStyle(color)
|
||||||
|
.frame(width: 24)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(title).font(.subheadline.bold()).foregroundStyle(color)
|
||||||
|
if let detail {
|
||||||
|
Text(detail).font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.background(color.opacity(0.12), in: RoundedRectangle(cornerRadius: 12))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.stroke(color.opacity(0.35), lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func asphaltColor(for level: GassiWetter.AsphaltLevel) -> Color {
|
||||||
|
switch level {
|
||||||
|
case .safe: return .green
|
||||||
|
case .warm: return .yellow
|
||||||
|
case .hot: return .orange
|
||||||
|
case .danger: return .red
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Schnüffel-Index
|
||||||
|
|
||||||
|
private func schnueffelCard(metrics: DayMetrics) -> some View {
|
||||||
|
let idx = GassiWetter.schnueffelIndex(tempMax: metrics.tempMax, precipProb: metrics.precipPct)
|
||||||
|
let color = Color(hex: idx.colorHex)
|
||||||
|
return HStack(spacing: 12) {
|
||||||
|
Text(idx.emoji).font(.title)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Schnüffel-Index").font(.caption.bold()).foregroundStyle(.secondary)
|
||||||
|
Text(idx.label).font(.subheadline.bold()).foregroundStyle(color)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.background(color.opacity(0.12), in: RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Loading
|
||||||
|
|
||||||
|
private func loadWeather() async {
|
||||||
guard let coord = location.coordinate else { return }
|
guard let coord = location.coordinate else { return }
|
||||||
isLoading = true
|
isLoading = true
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
defer { isLoading = false }
|
defer { isLoading = false }
|
||||||
|
let loc = CLLocation(latitude: coord.latitude, longitude: coord.longitude)
|
||||||
do {
|
do {
|
||||||
forecast = try await APIClient.shared.get(
|
weather = try await WeatherService.shared.weather(for: loc)
|
||||||
"/api/weather/forecast?lat=\(coord.latitude)&lon=\(coord.longitude)"
|
|
||||||
)
|
|
||||||
} catch {
|
} catch {
|
||||||
errorMessage = error.localizedDescription
|
errorMessage = error.localizedDescription
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct WeatherDayRow: View {
|
// MARK: - Day metrics derived from DayWeather
|
||||||
let day: WeatherDay
|
|
||||||
|
|
||||||
private var dateLabel: String {
|
private struct DayMetrics {
|
||||||
if day.date == today { return "Heute" }
|
let tempMax: Double
|
||||||
if day.date == tomorrow { return "Morgen" }
|
let tempMin: Double
|
||||||
return day.wday ?? day.date
|
let precipPct: Int
|
||||||
}
|
let windKmh: Double
|
||||||
|
let uvIndex: Int
|
||||||
|
let thunderstorm: Bool
|
||||||
|
let asphalt: Double
|
||||||
|
let gassiScore: Int
|
||||||
|
|
||||||
private var today: String {
|
init(day: DayWeather) {
|
||||||
let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"
|
let tMax = day.highTemperature.converted(to: .celsius).value
|
||||||
return f.string(from: .now)
|
let tMin = day.lowTemperature.converted(to: .celsius).value
|
||||||
}
|
let pct = Int((day.precipitationChance * 100).rounded())
|
||||||
private var tomorrow: String {
|
let wind = day.wind.speed.converted(to: .kilometersPerHour).value
|
||||||
let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"
|
let uv = day.uvIndex.value
|
||||||
let d = Calendar.current.date(byAdding: .day, value: 1, to: .now) ?? .now
|
let thunder = [
|
||||||
return f.string(from: d)
|
WeatherCondition.thunderstorms,
|
||||||
}
|
.strongStorms,
|
||||||
|
.scatteredThunderstorms,
|
||||||
|
.isolatedThunderstorms
|
||||||
|
].contains(day.condition)
|
||||||
|
let asphaltT = GassiWetter.asphaltTemp(airMax: tMax, uvMax: Double(uv))
|
||||||
|
|
||||||
private var weatherSymbol: String {
|
self.tempMax = tMax
|
||||||
switch day.weathercode ?? 0 {
|
self.tempMin = tMin
|
||||||
case 0: return "sun.max.fill"
|
self.precipPct = pct
|
||||||
case 1, 2: return "cloud.sun.fill"
|
self.windKmh = wind
|
||||||
case 3: return "cloud.fill"
|
self.uvIndex = uv
|
||||||
case 45, 48: return "cloud.fog.fill"
|
self.thunderstorm = thunder
|
||||||
case 51...57: return "cloud.drizzle.fill"
|
self.asphalt = asphaltT
|
||||||
case 61...67: return "cloud.rain.fill"
|
self.gassiScore = GassiWetter.gassiScore(
|
||||||
case 71...77: return "cloud.snow.fill"
|
tempMax: tMax,
|
||||||
case 80...82: return "cloud.heavyrain.fill"
|
precipProb: pct,
|
||||||
case 95...99: return "cloud.bolt.rain.fill"
|
windKmh: wind,
|
||||||
default: return "cloud"
|
asphalt: asphaltT,
|
||||||
}
|
thunderstorm: thunder
|
||||||
}
|
)
|
||||||
|
|
||||||
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