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%.
479 lines
19 KiB
Swift
479 lines
19 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)
|
||
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" : "06–22 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
|
||
)
|
||
}
|
||
}
|