banyaro-ios/BanYaroGo/Views/WetterView.swift
rene 5c4754caff Wetter: stündliche Niederschlags-Timeline (WeatherKit hourlyForecast)
Säulen-Diagramm zwischen Stats-Grid und Hunde-Hinweisen:
- Heute: nächste 12h ab jetzt
- Andere Tage: 06–22h des Tages
- Säulenhöhe aus precipitationAmount in mm, Farbgradient aus Intensität
  (chance + mm). Header zeigt durchschnittliche Regenwahrscheinlichkeit
  oder 'trocken' wenn < 5%.
2026-05-30 13:51:14 +02:00

479 lines
19 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
rainTimeline(weather: weather, day: day, isToday: safeIndex == 0)
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: - Niederschlags-Timeline
private func rainTimeline(weather: Weather, day: DayWeather, isToday: Bool) -> some View {
let hours = relevantHours(weather: weather, day: day, isToday: isToday)
let maxMm = max(hours.map { $0.precipitationAmount.converted(to: .millimeters).value }.max() ?? 0, 0.5)
let totalChance = hours.reduce(0.0) { $0 + $1.precipitationChance } / Double(max(1, hours.count))
return VStack(alignment: .leading, spacing: 10) {
HStack {
Label("Niederschlag", systemImage: "umbrella.fill")
.font(.headline)
.foregroundStyle(.blue)
Spacer()
Text(headerLabel(isToday: isToday, totalChance: totalChance))
.font(.caption)
.foregroundStyle(.secondary)
}
if hours.isEmpty {
Text("Keine stündlichen Daten verfügbar.")
.font(.caption)
.foregroundStyle(.secondary)
} else {
ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .bottom, spacing: 6) {
ForEach(Array(hours.enumerated()), id: \.offset) { _, hour in
rainBar(for: hour, scaleMm: maxMm)
}
}
.frame(height: 110)
}
}
}
.padding(14)
.background(.background.secondary, in: RoundedRectangle(cornerRadius: 12))
}
private func relevantHours(weather: Weather, day: DayWeather, isToday: Bool) -> [HourWeather] {
let all = weather.hourlyForecast
if isToday {
// Ab jetzt die nächsten 12 Stunden
return Array(all.filter { $0.date >= .now }.prefix(12))
}
// Der ausgewählte Tag, 06:00 bis 22:00
let cal = Calendar.current
return all
.filter { cal.isDate($0.date, inSameDayAs: day.date) }
.filter {
let h = cal.component(.hour, from: $0.date)
return h >= 6 && h <= 22
}
}
private func rainBar(for hour: HourWeather, scaleMm: Double) -> some View {
let chance = hour.precipitationChance
let mm = hour.precipitationAmount.converted(to: .millimeters).value
// Höhe primär aus mm, mindestens 6 (auch bei 0 sichtbarer Bar-Sockel)
let height: CGFloat = max(6, CGFloat(mm / scaleMm) * 50)
let intensity: Double = min(1.0, chance + mm / 5)
let baseColor = Color.blue
return VStack(spacing: 3) {
Text("\(Int((chance * 100).rounded()))%")
.font(.caption2.monospacedDigit())
.foregroundStyle(chance >= 0.3 ? .primary : .secondary)
Rectangle()
.fill(LinearGradient(
colors: [baseColor.opacity(0.4 + 0.6 * intensity), baseColor.opacity(0.2 + 0.2 * intensity)],
startPoint: .top,
endPoint: .bottom
))
.frame(width: 22, height: height)
.clipShape(RoundedRectangle(cornerRadius: 4))
Text(hourLabel(hour.date))
.font(.caption2.monospacedDigit())
.foregroundStyle(.secondary)
}
.frame(height: 100, alignment: .bottom)
}
private func hourLabel(_ date: Date) -> String {
let f = DateFormatter()
f.locale = Locale(identifier: "de_DE")
f.dateFormat = "HH"
return "\(f.string(from: date)):00"
}
private func headerLabel(isToday: Bool, totalChance: Double) -> String {
let pct = Int((totalChance * 100).rounded())
let prefix = isToday ? "nächste 12 h" : "0622 h"
if totalChance < 0.05 { return "\(prefix) · trocken" }
return "\(prefix) · ⌀ \(pct) % Regen"
}
// 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
)
}
}