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 ) } }