- 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
385 lines
15 KiB
Swift
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
|
|
)
|
|
}
|
|
}
|