banyaro-ios/BanYaroGo/Views/WetterView.swift
rene 08069d6ea4 Wetter: deutsche Conditions + Asphalt-Label entrümpelt
- WeatherCondition deutsch: 'Mostly Clear' → 'Überwiegend klar' etc.
- AsphaltLevel.label nur noch ein Wort (Heiß/Warm/Gefährlich),
  die safety-Info wandert in den advice-Text → Title bleibt einzeilig
2026-05-30 13:30:01 +02:00

385 lines
15 KiB
Swift

import SwiftUI
import WeatherKit
import CoreLocation
struct WetterView: View {
@State private var location = OneShotLocation()
@State private var weather: Weather?
@State private var isLoading = false
@State private var errorMessage: String?
@State private var selectedDayIndex = 0
var body: some View {
Group {
if let weather {
content(weather: weather)
} else if isLoading {
ProgressView("Lade Wetter…")
} else if let errorMessage {
ContentUnavailableView(
"Konnte nicht laden",
systemImage: "cloud.slash",
description: Text(errorMessage)
)
} else if location.error != nil {
ContentUnavailableView(
"Kein Standort",
systemImage: "location.slash",
description: Text(location.error ?? "")
)
} else {
ProgressView("Hole Standort…")
}
}
.navigationTitle("Gassi-Wetter")
.navigationBarTitleDisplayMode(.inline)
.task { location.request() }
.onChange(of: location.coordinate?.latitude) { _, _ in
Task { await loadWeather() }
}
.refreshable { await loadWeather() }
}
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(germanCondition(day.condition))
.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 germanCondition(_ condition: WeatherCondition) -> String {
switch condition {
case .clear: return "Klar"
case .mostlyClear: return "Überwiegend klar"
case .partlyCloudy: return "Teilweise bewölkt"
case .mostlyCloudy: return "Überwiegend bewölkt"
case .cloudy: return "Bedeckt"
case .foggy: return "Nebel"
case .haze: return "Dunst"
case .smoky: return "Rauch in der Luft"
case .drizzle: return "Nieselregen"
case .freezingDrizzle: return "Gefrierender Nieselregen"
case .rain: return "Regen"
case .heavyRain: return "Starker Regen"
case .freezingRain: return "Gefrierender Regen"
case .sunShowers: return "Sonnige Schauer"
case .snow: return "Schnee"
case .heavySnow: return "Starker Schnee"
case .flurries: return "Schneetreiben"
case .blowingSnow: return "Schneeverwehung"
case .blizzard: return "Schneesturm"
case .sleet: return "Schneeregen"
case .hail: return "Hagel"
case .wintryMix: return "Schneeregen-Mix"
case .thunderstorms: return "Gewitter"
case .strongStorms: return "Starkes Gewitter"
case .scatteredThunderstorms: return "Vereinzelte Gewitter"
case .isolatedThunderstorms: return "Einzelne Gewitter"
case .tropicalStorm: return "Tropensturm"
case .hurricane: return "Hurrikan"
case .hot: return "Hitze"
case .frigid: return "Eisige Kälte"
case .windy: return "Windig"
case .breezy: return "Leichte Brise"
case .blowingDust: return "Staubsturm"
case .sunFlurries: return "Schneetreiben"
default: return condition.description
}
}
private func loadWeather() async {
guard let coord = location.coordinate else { return }
isLoading = true
errorMessage = nil
defer { isLoading = false }
let loc = CLLocation(latitude: coord.latitude, longitude: coord.longitude)
do {
weather = try await WeatherService.shared.weather(for: loc)
} catch {
errorMessage = error.localizedDescription
}
}
}
// MARK: - Day metrics derived from DayWeather
private struct DayMetrics {
let tempMax: Double
let tempMin: Double
let precipPct: Int
let windKmh: Double
let uvIndex: Int
let thunderstorm: Bool
let asphalt: Double
let gassiScore: Int
init(day: DayWeather) {
let tMax = day.highTemperature.converted(to: .celsius).value
let tMin = day.lowTemperature.converted(to: .celsius).value
let pct = Int((day.precipitationChance * 100).rounded())
let wind = day.wind.speed.converted(to: .kilometersPerHour).value
let uv = day.uvIndex.value
let thunder = [
WeatherCondition.thunderstorms,
.strongStorms,
.scatteredThunderstorms,
.isolatedThunderstorms
].contains(day.condition)
let asphaltT = GassiWetter.asphaltTemp(airMax: tMax, uvMax: Double(uv))
self.tempMax = tMax
self.tempMin = tMin
self.precipPct = pct
self.windKmh = wind
self.uvIndex = uv
self.thunderstorm = thunder
self.asphalt = asphaltT
self.gassiScore = GassiWetter.gassiScore(
tempMax: tMax,
precipProb: pct,
windKmh: wind,
asphalt: asphaltT,
thunderstorm: thunder
)
}
}