banyaro-ios/BanYaroGo/Views/WetterView.swift
rene a2646a18ef 1.1: Offline-Cache + Outbox für Touren/Tagebuch, WeatherKit-Fix, Aufräumen
App-Review-Fix (Guideline 2.1 WeatherKit):
- OneShotLocation: deterministisches async resolve() mit 10s-Timeout statt
  onChange-Lauschen; WetterView lädt bei fehlendem Standort einen Berlin-Fallback
  → kein ewiges Hängen bei "Hole Standort…", WeatherKit ist immer sichtbar.

Offline-Lesen (SwiftData):
- CachedRoute/CachedDiaryEntry/CachedImage + CachedAsyncImage: Touren, Tagebuch
  und Fotos werden cache-first geladen und sind offline verfügbar.
- Cache wird bei Logout/401 geleert (RootView), kein Durchschimmern fremder User.

Offline-Speichern (Outbox):
- PendingRoute/PendingRoutePhoto: Tour inkl. unterwegs hinzugefügter Fotos wird
  offline lokal gesichert und automatisch hochgeladen (Touren-Tab + App-Start).
- Touren-Liste zeigt offline gesicherte Touren mit "wird hochgeladen"-Badge.

FinishWalkSheet:
- Dismiss-Schutz: Speichern-Dialog lässt sich nicht mehr wegwischen — eine
  aufgezeichnete Tour geht nicht mehr durch Runterwischen verloren.

Wetter:
- Ortslabel (Reverse-Geocoding; Fallback "Berlin · Näherung").
- Saubere Offline-Meldung statt rohem networkError.

Aufräumen:
- Doppeltes "Gassi-Treffen" im Mehr-Tab entfernt.
- Veraltete Phase-1/2-Texte neu getextet.
- Tote DogsListView gelöscht (Hund-Wechsel läuft über den Heim-Picker).
2026-06-02 19:37:30 +02:00

528 lines
21 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
@State private var placeName: String?
@State private var isApproxLocation = false
/// Fallback-Standort (Berlin-Mitte): Wenn der Gerätestandort nicht
/// ermittelbar ist (Timeout/verweigert z. B. Apple-Review-iPad ohne
/// Position), lädt WeatherKit trotzdem eine Vorhersage, statt ewig bei
/// Hole Standort" zu hängen.
private static let fallbackCoordinate = CLLocationCoordinate2D(
latitude: 52.5200, longitude: 13.4050
)
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 {
// Deterministisch: erst Standort auflösen (Fix/Fehler/Timeout),
// dann laden mit echtem Standort oder Berlin-Fallback. So hängt
// der Screen nie bei Hole Standort" und WeatherKit lädt immer.
let coord = await location.resolve()
isApproxLocation = (coord == nil)
let used = coord ?? Self.fallbackCoordinate
await loadWeather(coord: used)
placeName = isApproxLocation ? "Berlin" : await Self.reverseGeocode(used)
}
.refreshable {
let coord = location.coordinate
isApproxLocation = (coord == nil)
let used = coord ?? Self.fallbackCoordinate
await loadWeather(coord: used)
placeName = isApproxLocation ? "Berlin" : await Self.reverseGeocode(used)
}
}
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 let placeName {
HStack(spacing: 5) {
Image(systemName: isApproxLocation ? "location.slash" : "location.fill")
Text(placeName)
if isApproxLocation {
Text("· Näherung").foregroundStyle(.secondary)
}
}
.font(.subheadline)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
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(coord: CLLocationCoordinate2D) async {
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 = Self.isOfflineError(error)
? "Wetter ist offline nicht verfügbar. Die Vorhersage lädt automatisch, sobald du wieder Internet hast."
: error.localizedDescription
}
}
/// Reverse-Geocoding Ortsname (Stadt). Braucht Netz; offline nil.
private static func reverseGeocode(_ coord: CLLocationCoordinate2D) async -> String? {
let loc = CLLocation(latitude: coord.latitude, longitude: coord.longitude)
let placemarks = try? await CLGeocoder().reverseGeocodeLocation(loc)
return placemarks?.first?.locality ?? placemarks?.first?.name
}
private static func isOfflineError(_ error: Error) -> Bool {
if error is URLError { return true }
let d = error.localizedDescription.lowercased()
return d.contains("internet") || d.contains("verbindung")
|| d.contains("network") || d.contains("offline")
}
}
// 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
)
}
}