From 357c57e880e4c4f037b223437e3a83cc7c3013b8 Mon Sep 17 00:00:00 2001 From: rene Date: Sat, 30 May 2026 13:16:48 +0200 Subject: [PATCH] Gassi-Wetter mit WeatherKit + banyaro-Logik MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WeatherKit als Datenquelle (statt OpenMeteo-Proxy via banyaro-Backend): - BanYaroGo.entitlements: com.apple.developer.weatherkit - WetterView komplett neu mit WeatherService.shared.weather(for:) - DayWeather.symbolName als SF-Symbol direkt, kein WMO-Mapping nötig GassiWetter-Logik (1:1-Port aus banyaro PWA wetter.js): - gassiScore(...) 1-10 mit Temp/Regen/Wind/Asphalt/Gewitter - asphaltTemp(airMax, uvMax) — gleiche Formel mit t_factor und UV-Bonus - asphaltLevel safe/warm/hot/danger mit Advice-Texten - schnueffelIndex aus Feuchte (precipProb-derived) und Temperatur - tickRisk March-Oktober, Schwellen 7/12/20°C - pawColdProtection bei tempMin <= 0 UI: - Horizontaler Tag-Picker (Heute/Morgen + EEE) mit Mini-Stats - Großer Gassi-Score-Badge in Empfehlungs-Farbe (grün/amber/rot) - Stats-Grid 2x2: Niederschlag, Wind, UV, Asphalt - Hunde-Hinweise als farbige Boxen (Asphalt, Pfoten, Gewitter, Zecken) - Schnüffel-Index als kompakte Karte mit Emoji Color(hex:)-Extension für die HEX-Werte aus dem PWA übernommen. --- BanYaroGo.entitlements | 2 + BanYaroGo/Support/ColorHex.swift | 20 ++ BanYaroGo/Support/GassiWetter.swift | 182 ++++++++++++ BanYaroGo/Views/WetterView.swift | 423 ++++++++++++++++++++-------- 4 files changed, 502 insertions(+), 125 deletions(-) create mode 100644 BanYaroGo/Support/ColorHex.swift create mode 100644 BanYaroGo/Support/GassiWetter.swift diff --git a/BanYaroGo.entitlements b/BanYaroGo.entitlements index 2ab14a2..03d07da 100644 --- a/BanYaroGo.entitlements +++ b/BanYaroGo.entitlements @@ -6,5 +6,7 @@ com.apple.developer.healthkit.access + com.apple.developer.weatherkit + diff --git a/BanYaroGo/Support/ColorHex.swift b/BanYaroGo/Support/ColorHex.swift new file mode 100644 index 0000000..044b1d7 --- /dev/null +++ b/BanYaroGo/Support/ColorHex.swift @@ -0,0 +1,20 @@ +import SwiftUI + +extension Color { + /// Creates a Color from a 6-digit hex string (e.g. "10B981" or "#10B981"). + /// Falls back to system gray on bad input. + init(hex: String) { + let cleaned = hex.trimmingCharacters(in: CharacterSet(charactersIn: "#")) + var value: UInt64 = 0 + Scanner(string: cleaned).scanHexInt64(&value) + + guard cleaned.count == 6 else { + self.init(.sRGB, red: 0.5, green: 0.5, blue: 0.5) + return + } + let r = Double((value >> 16) & 0xFF) / 255 + let g = Double((value >> 8) & 0xFF) / 255 + let b = Double(value & 0xFF) / 255 + self.init(.sRGB, red: r, green: g, blue: b) + } +} diff --git a/BanYaroGo/Support/GassiWetter.swift b/BanYaroGo/Support/GassiWetter.swift new file mode 100644 index 0000000..50bfdf4 --- /dev/null +++ b/BanYaroGo/Support/GassiWetter.swift @@ -0,0 +1,182 @@ +import Foundation + +/// Gassi-Wetter-Bewertung — 1:1-Port der Logik aus banyaro PWA (wetter.js). +/// Asphalt-Formel, Gassi-Score-Komposition, Schnüffel-Index und Zecken- +/// Heuristik identisch, damit PWA und Go App dieselbe Empfehlung geben. +enum GassiWetter { + + // MARK: - Asphalt-Temperatur (aus Lufttemp + UV-Index) + + static func asphaltTemp(airMax: Double, uvMax: Double) -> Double { + // UV-Bonus skaliert mit Temperatur: unter 5°C kaum Aufheizung, ab 30°C voll + let tFactor = max(0.0, min(1.0, (airMax - 5) / 25)) + let bonus = min(uvMax * 3.0 * tFactor, 30.0) + return (airMax + bonus).rounded(toPlaces: 1) + } + + enum AsphaltLevel { + case safe, warm, hot, danger + + var label: String { + switch self { + case .safe: return "Unbedenklich" + case .warm: return "Warm — kurze Runden ok" + case .hot: return "Heiß — Pfoten verbrennen" + case .danger: return "Gefährlich — gar nicht laufen" + } + } + + var advice: String? { + switch self { + case .safe: return nil + case .warm: return "Schatten suchen, Pausen einlegen." + case .hot: return "Gras oder Schatten bevorzugen — Asphalt vermeiden." + case .danger: return "Nur Morgens/Abends rausgehen, mittags drinnen." + } + } + } + + static func asphaltLevel(for asphalt: Double) -> AsphaltLevel { + switch asphalt { + case ..<30: return .safe + case ..<40: return .warm + case ..<55: return .hot + default: return .danger + } + } + + // MARK: - Gassi-Score (1–10) + + static func gassiScore( + tempMax: Double, + precipProb: Int, + windKmh: Double, + asphalt: Double, + thunderstorm: Bool + ) -> Int { + var score = 10 + + // Temperatur (ideal 10–20°C) + if tempMax > 30 { score -= 3 } + else if tempMax > 25 { score -= 1 } + else if tempMax < 0 { score -= 3 } + else if tempMax < 5 { score -= 1 } + + // Regen + if precipProb > 70 { score -= 3 } + else if precipProb > 40 { score -= 2 } + else if precipProb > 20 { score -= 1 } + + // Wind + if windKmh > 60 { score -= 2 } + else if windKmh > 40 { score -= 1 } + + // Asphalt + if asphalt > 55 { score -= 2 } + else if asphalt > 45 { score -= 1 } + + // Gewitter + if thunderstorm { score -= 3 } + + return max(1, min(10, score)) + } + + struct GassiScoreBadge { + let score: Int + let label: String + let colorHex: String + + init(score: Int) { + self.score = score + if score >= 8 { + label = "Toller Gassi-Tag!" + colorHex = "10B981" + } else if score >= 5 { + label = "Geht so" + colorHex = "F59E0B" + } else { + label = "Lieber drinbleiben" + colorHex = "EF4444" + } + } + } + + // MARK: - Schnüffel-Index + + struct SchnueffelIndex { + let label: String + let emoji: String + let colorHex: String + } + + static func schnueffelIndex(tempMax: Double, precipProb: Int) -> SchnueffelIndex { + let feucht: Feuchte = precipProb > 60 ? .feucht + : precipProb > 30 ? .leicht + : .trocken + + if feucht == .feucht, tempMax >= 10, tempMax <= 18 { + return SchnueffelIndex(label: "Exzellent", emoji: "👃", colorHex: "10B981") + } + if feucht == .feucht, tempMax > 10, tempMax <= 22 { + return SchnueffelIndex(label: "Sehr gut", emoji: "👃", colorHex: "34D399") + } + if tempMax < 5 { + return SchnueffelIndex(label: "Gut (kalte Luft trägt Gerüche)", emoji: "🌬️", colorHex: "60A5FA") + } + if feucht == .leicht, tempMax >= 10, tempMax <= 22 { + return SchnueffelIndex(label: "Gut", emoji: "👃", colorHex: "4CAF50") + } + if feucht == .trocken, tempMax > 25 { + return SchnueffelIndex(label: "Schwach", emoji: "💨", colorHex: "94A3B8") + } + return SchnueffelIndex(label: "Mittel", emoji: "🌫", colorHex: "F59E0B") + } + + private enum Feuchte { case feucht, leicht, trocken } + + // MARK: - Zecken-Risiko + + enum TickRisk { + case none, low, medium, high + + var label: String { + switch self { + case .none: return "Keine" + case .low: return "Niedrig" + case .medium: return "Mittel" + case .high: return "Hoch" + } + } + + var colorHex: String { + switch self { + case .none: return "94A3B8" + case .low: return "10B981" + case .medium: return "F59E0B" + case .high: return "EF4444" + } + } + } + + static func tickRisk(tempMax: Double, month: Int) -> TickRisk { + // Wie in banyaro: nur März-Oktober relevant + guard (3...10).contains(month) else { return .none } + if tempMax <= 7 { return .none } + if tempMax > 20 { return .high } + if tempMax > 12 { return .medium } + return .low + } + + // MARK: - Pfoten-Kälteschutz + + static func pawColdProtection(tempMin: Double) -> Bool { + tempMin <= 0 + } +} + +private extension Double { + func rounded(toPlaces places: Int) -> Double { + let multiplier = pow(10.0, Double(places)) + return (self * multiplier).rounded() / multiplier + } +} diff --git a/BanYaroGo/Views/WetterView.swift b/BanYaroGo/Views/WetterView.swift index 3693814..3598049 100644 --- a/BanYaroGo/Views/WetterView.swift +++ b/BanYaroGo/Views/WetterView.swift @@ -1,31 +1,23 @@ import SwiftUI +import WeatherKit import CoreLocation struct WetterView: View { @State private var location = OneShotLocation() - @State private var forecast: WeatherForecast? + @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 forecast { - List { - Section { - ForEach(Array(forecast.days.prefix(7))) { day in - WeatherDayRow(day: day) - } - } footer: { - Text("Vorhersage von Open-Meteo. Hunde-Tipps basieren auf maximaler Tagestemperatur und Asphalt-Hitze.") - .font(.caption2) - .foregroundStyle(.tertiary) - } - } + if let weather { + content(weather: weather) } else if isLoading { ProgressView("Lade Wetter…") } else if let errorMessage { ContentUnavailableView( - "Wetter konnte nicht geladen werden", + "Konnte nicht laden", systemImage: "cloud.slash", description: Text(errorMessage) ) @@ -33,140 +25,321 @@ struct WetterView: View { ContentUnavailableView( "Kein Standort", systemImage: "location.slash", - description: Text(location.error ?? "Bitte Standort erlauben.") + description: Text(location.error ?? "") ) } else { ProgressView("Hole Standort…") } } - .navigationTitle("Wetter") + .navigationTitle("Gassi-Wetter") .navigationBarTitleDisplayMode(.inline) - .task { - location.request() - } + .task { location.request() } .onChange(of: location.coordinate?.latitude) { _, _ in - Task { await load() } + Task { await loadWeather() } } - .refreshable { await load() } + .refreshable { await loadWeather() } } - private func load() async { + 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(day.condition.description) + .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 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 { - forecast = try await APIClient.shared.get( - "/api/weather/forecast?lat=\(coord.latitude)&lon=\(coord.longitude)" - ) + weather = try await WeatherService.shared.weather(for: loc) } catch { errorMessage = error.localizedDescription } } } -private struct WeatherDayRow: View { - let day: WeatherDay +// MARK: - Day metrics derived from DayWeather - private var dateLabel: String { - if day.date == today { return "Heute" } - if day.date == tomorrow { return "Morgen" } - return day.wday ?? day.date - } +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 - private var today: String { - let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd" - return f.string(from: .now) - } - private var tomorrow: String { - let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd" - let d = Calendar.current.date(byAdding: .day, value: 1, to: .now) ?? .now - return f.string(from: d) - } + 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)) - private var weatherSymbol: String { - switch day.weathercode ?? 0 { - case 0: return "sun.max.fill" - case 1, 2: return "cloud.sun.fill" - case 3: return "cloud.fill" - case 45, 48: return "cloud.fog.fill" - case 51...57: return "cloud.drizzle.fill" - case 61...67: return "cloud.rain.fill" - case 71...77: return "cloud.snow.fill" - case 80...82: return "cloud.heavyrain.fill" - case 95...99: return "cloud.bolt.rain.fill" - default: return "cloud" - } - } - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 12) { - Image(systemName: weatherSymbol) - .font(.title2) - .foregroundStyle(Color.accentColor) - .frame(width: 36) - VStack(alignment: .leading, spacing: 2) { - Text(dateLabel).font(.headline) - if let desc = day.desc { Text(desc).font(.caption).foregroundStyle(.secondary) } - } - Spacer() - tempColumn - } - - if !tips.isEmpty { - VStack(alignment: .leading, spacing: 4) { - ForEach(Array(tips.enumerated()), id: \.offset) { _, tip in - Label(tip.text, systemImage: tip.icon) - .font(.caption) - .foregroundStyle(tip.color) - } - } - .padding(.leading, 48) - } - } - .padding(.vertical, 4) - } - - private var tempColumn: some View { - HStack(alignment: .firstTextBaseline, spacing: 6) { - if let max = day.tempMax { - Text("\(Int(max.rounded()))°").font(.headline.monospacedDigit()) - } - if let min = day.tempMin { - Text("\(Int(min.rounded()))°") - .font(.subheadline.monospacedDigit()) - .foregroundStyle(.secondary) - } - } - } - - private struct Tip { - let text: String - let icon: String - let color: Color - } - - private var tips: [Tip] { - var result: [Tip] = [] - if let max = day.tempMax { - if max >= 30 { - result.append(Tip(text: "Extreme Hitze — Gassi nur früh morgens/abends", icon: "sun.dust.fill", color: .red)) - } else if max >= 25 { - result.append(Tip(text: "Warm — Pfoten auf Asphalt prüfen", icon: "thermometer.sun", color: .orange)) - } else if max <= 0 { - result.append(Tip(text: "Frost — Pfoten nach Streusalz abwischen", icon: "snowflake", color: .blue)) - } - } - if let asphalt = day.asphaltTemp, asphalt >= 50 { - result.append(Tip(text: "Asphalt ~\(Int(asphalt.rounded()))°C — verbrennungsgefahr", icon: "flame.fill", color: .red)) - } - if let zecken = day.zecken, zecken == "hoch" { - result.append(Tip(text: "Hohe Zecken-Gefahr", icon: "ant.fill", color: .orange)) - } - if let pp = day.precipProb, pp >= 70 { - result.append(Tip(text: "Regen wahrscheinlich (\(pp) %)", icon: "umbrella.fill", color: .blue)) - } - return result + 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 + ) } }