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" : "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(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 ) } }