diff --git a/BanYaroGo/API/DTOs.swift b/BanYaroGo/API/DTOs.swift index 194e9f3..babe51a 100644 --- a/BanYaroGo/API/DTOs.swift +++ b/BanYaroGo/API/DTOs.swift @@ -89,6 +89,128 @@ struct RouteCreateBody: Encodable { let isPublic: Bool } +// MARK: - Expenses + +struct Expense: Decodable, Identifiable { + let id: Int + let dogId: Int? + let kategorie: String + let betrag: Double + let datum: String + let notiz: String? + let dogName: String? +} + +struct ExpenseCreateBody: Encodable { + let dogId: Int? + let kategorie: String + let betrag: Double + let datum: String + let notiz: String? +} + +// MARK: - Gassi-Zeiten + +struct GassiZeit: Decodable, Identifiable { + let id: Int + let dogId: Int? + let wochentage: [String] + let uhrzeit: String + let ortName: String? + let lat: Double? + let lon: Double? + let radiusM: Int? + let notiz: String? + let aktiv: Int? + let distanceM: Int? + let isMine: Bool? + let userName: String? + let dogName: String? + let dogRasse: String? +} + +struct GassiZeitCreateBody: Encodable { + let dogId: Int? + let wochentage: [String] + let uhrzeit: String + let ortName: String? + let lat: Double? + let lon: Double? + let radiusM: Int + let notiz: String? +} + +// MARK: - Poison + +struct PoisonAlert: Decodable, Identifiable { + let id: Int + let lat: Double + let lon: Double + let beschreibung: String? + let typ: String? + let distanzM: Int? + let fotoUrl: String? + let melderName: String? + let createdAt: String? +} + +struct PoisonCreateBody: Encodable { + let lat: Double + let lon: Double + let beschreibung: String? + let typ: String +} + +// MARK: - Lost Dogs + +struct LostDog: Decodable, Identifiable { + let id: Int + let name: String + let rasse: String? + let beschreibung: String + let lat: Double + let lon: Double + let distanzM: Int? + let fotoUrl: String? + let melderName: String? + let createdAt: String? +} + +struct LostDogCreateBody: Encodable { + let name: String + let rasse: String? + let beschreibung: String + let lat: Double + let lon: Double + let dogId: Int? +} + +// MARK: - Weather + +struct WeatherForecast: Decodable { + let days: [WeatherDay] +} + +struct WeatherDay: Decodable, Identifiable { + let date: String + let wday: String? + let weathercode: Int? + let desc: String? + let icon: String? + let tempMax: Double? + let tempMin: Double? + let precipProb: Int? + let precipSum: Double? + let windKmh: Double? + let uvIndex: Double? + let sunrise: String? + let sunset: String? + let asphaltTemp: Double? + let zecken: String? + + var id: String { date } +} + /// Patch body for PATCH /api/routes/{id}. Only non-nil fields are encoded. struct RouteUpdateBody: Encodable { var name: String? diff --git a/BanYaroGo/Support/GassiZeitenScheduler.swift b/BanYaroGo/Support/GassiZeitenScheduler.swift new file mode 100644 index 0000000..c24eab4 --- /dev/null +++ b/BanYaroGo/Support/GassiZeitenScheduler.swift @@ -0,0 +1,58 @@ +import Foundation +import UserNotifications + +/// Schedules local repeating notifications for Gassi-Zeiten so reminders work +/// even when the app is offline. One UNCalendarNotificationTrigger per weekday. +@MainActor +enum GassiZeitenScheduler { + static func reschedule(_ z: GassiZeit) async { + cancel(forId: z.id) + guard z.aktiv != 0 else { return } + + let parts = z.uhrzeit.split(separator: ":") + guard parts.count == 2, + let h = Int(parts[0]), + let m = Int(parts[1]) + else { return } + + let content = UNMutableNotificationContent() + content.title = "Gassi-Zeit" + content.body = z.notiz?.isEmpty == false ? z.notiz! : "Zeit für deine Gassi-Runde." + content.sound = .default + + let center = UNUserNotificationCenter.current() + for wt in z.wochentage { + let weekday = weekdayNumber(for: wt) + guard weekday > 0 else { continue } + var comps = DateComponents() + comps.weekday = weekday + comps.hour = h + comps.minute = m + let trigger = UNCalendarNotificationTrigger(dateMatching: comps, repeats: true) + let request = UNNotificationRequest( + identifier: "gz-\(z.id)-\(weekday)", + content: content, + trigger: trigger + ) + try? await center.add(request) + } + } + + static func cancel(forId id: Int) { + let ids = (1...7).map { "gz-\(id)-\($0)" } + UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: ids) + } + + private static func weekdayNumber(for code: String) -> Int { + switch code.lowercased() { + case "so": return 1 + case "mo": return 2 + case "di": return 3 + case "mi": return 4 + case "do": return 5 + case "fr": return 6 + case "sa": return 7 + default: return 0 + } + } +} diff --git a/BanYaroGo/Support/OneShotLocation.swift b/BanYaroGo/Support/OneShotLocation.swift new file mode 100644 index 0000000..0aa9794 --- /dev/null +++ b/BanYaroGo/Support/OneShotLocation.swift @@ -0,0 +1,73 @@ +import Foundation +import Observation +import CoreLocation + +/// Asks CLLocationManager for the user's current location once. Used by +/// Wetter and Giftköder which need a position without the full tracking setup. +@Observable +@MainActor +final class OneShotLocation: NSObject, CLLocationManagerDelegate { + private let manager = CLLocationManager() + + var coordinate: CLLocationCoordinate2D? + var error: String? + var isResolving: Bool = false + + override init() { + super.init() + manager.delegate = self + manager.desiredAccuracy = kCLLocationAccuracyHundredMeters + } + + func request() { + error = nil + isResolving = true + switch manager.authorizationStatus { + case .notDetermined: + manager.requestWhenInUseAuthorization() + case .denied, .restricted: + error = "Standortzugriff verweigert." + isResolving = false + case .authorizedWhenInUse, .authorizedAlways: + manager.requestLocation() + @unknown default: + error = "Unbekannter Standort-Status." + isResolving = false + } + } + + nonisolated func locationManager( + _ manager: CLLocationManager, + didUpdateLocations locations: [CLLocation] + ) { + guard let loc = locations.first else { return } + let c = loc.coordinate + Task { @MainActor in + self.coordinate = c + self.isResolving = false + } + } + + nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError err: Error) { + let msg = err.localizedDescription + Task { @MainActor in + self.error = msg + self.isResolving = false + } + } + + nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + let status = manager.authorizationStatus + Task { @MainActor in + switch status { + case .authorizedWhenInUse, .authorizedAlways: + manager.requestLocation() + case .denied, .restricted: + self.error = "Standortzugriff verweigert." + self.isResolving = false + default: + break + } + } + } +} diff --git a/BanYaroGo/Views/AusgabenView.swift b/BanYaroGo/Views/AusgabenView.swift new file mode 100644 index 0000000..a36d8c9 --- /dev/null +++ b/BanYaroGo/Views/AusgabenView.swift @@ -0,0 +1,228 @@ +import SwiftUI + +struct AusgabenView: View { + @State private var expenses: [Expense] = [] + @State private var isLoading = false + @State private var errorMessage: String? + @State private var showAdd = false + + private static let dateFormatter: DateFormatter = { + let f = DateFormatter() + f.locale = Locale(identifier: "de_DE") + f.dateStyle = .medium + return f + }() + + private let parseFormatter: DateFormatter = { + let f = DateFormatter() + f.locale = Locale(identifier: "en_US_POSIX") + f.dateFormat = "yyyy-MM-dd" + return f + }() + + var body: some View { + content + .navigationTitle("Ausgaben") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { showAdd = true } label: { Image(systemName: "plus") } + } + } + .sheet(isPresented: $showAdd) { + AddExpenseSheet { Task { await load() } } + } + .task { await load() } + .refreshable { await load() } + } + + @ViewBuilder + private var content: some View { + if isLoading && expenses.isEmpty { + ProgressView() + } else if let errorMessage, expenses.isEmpty { + ContentUnavailableView("Konnte Ausgaben nicht laden", systemImage: "wifi.slash", description: Text(errorMessage)) + } else if expenses.isEmpty { + ContentUnavailableView("Noch keine Ausgaben", systemImage: "eurosign.circle", description: Text("Tippe oben rechts auf +, um eine Ausgabe hinzuzufügen.")) + } else { + List { + Section { + LabeledContent("Gesamt", value: totalLabel) + .font(.headline) + } + Section { + ForEach(expenses) { e in + ExpenseRow(expense: e, dateLabel: formatDate(e.datum)) + } + } + } + } + } + + private var totalLabel: String { + let total = expenses.reduce(0) { $0 + $1.betrag } + return String(format: "%.2f €", total) + } + + private func formatDate(_ s: String) -> String { + if let d = parseFormatter.date(from: String(s.prefix(10))) { + return Self.dateFormatter.string(from: d) + } + return s + } + + private func load() async { + isLoading = true + errorMessage = nil + defer { isLoading = false } + do { + expenses = try await APIClient.shared.get("/api/expenses") + } catch { + errorMessage = error.localizedDescription + } + } +} + +private struct ExpenseRow: View { + let expense: Expense + let dateLabel: String + + var body: some View { + HStack(spacing: 12) { + Image(systemName: icon(for: expense.kategorie)) + .foregroundStyle(Color.accentColor) + .frame(width: 28) + VStack(alignment: .leading, spacing: 2) { + Text(expense.kategorie).font(.subheadline.bold()) + HStack(spacing: 6) { + Text(dateLabel) + if let dog = expense.dogName { + Text("• \(dog)") + } + } + .font(.caption) + .foregroundStyle(.secondary) + if let n = expense.notiz, !n.isEmpty { + Text(n).font(.caption).foregroundStyle(.secondary).lineLimit(1) + } + } + Spacer() + Text(String(format: "%.2f €", expense.betrag)) + .font(.subheadline.monospacedDigit().bold()) + } + .padding(.vertical, 4) + } + + private func icon(for kategorie: String) -> String { + switch kategorie.lowercased() { + case "futter", "essen": return "fork.knife" + case "tierarzt": return "stethoscope" + case "versicherung": return "shield.lefthalf.filled" + case "spielzeug": return "tennisball.fill" + case "pflege", "fellpflege": return "scissors" + case "training", "hundeschule": return "graduationcap.fill" + case "leckerli", "snacks": return "carrot.fill" + default: return "eurosign.circle" + } + } +} + +private struct AddExpenseSheet: View { + @Environment(\.dismiss) private var dismiss + let onSaved: () -> Void + + @State private var kategorie = "Futter" + @State private var betrag = "" + @State private var date = Date() + @State private var notiz = "" + @State private var dogs: [Dog] = [] + @State private var dogId: Int? + @State private var isSaving = false + @State private var errorMessage: String? + + private let kategorien = ["Futter", "Tierarzt", "Versicherung", "Spielzeug", "Pflege", "Training", "Leckerli", "Sonstiges"] + + var body: some View { + NavigationStack { + Form { + Section("Kategorie") { + Picker("Kategorie", selection: $kategorie) { + ForEach(kategorien, id: \.self) { Text($0) } + } + } + Section("Betrag") { + TextField("0,00", text: $betrag) + .keyboardType(.decimalPad) + } + Section("Datum") { + DatePicker("Datum", selection: $date, displayedComponents: .date) + .environment(\.locale, Locale(identifier: "de_DE")) + } + Section("Hund (optional)") { + Picker("Hund", selection: $dogId) { + Text("Ohne Hund").tag(Int?.none) + ForEach(dogs) { dog in + Text(dog.name).tag(Int?.some(dog.id)) + } + } + } + Section("Notiz (optional)") { + TextField("Notiz", text: $notiz, axis: .vertical) + .lineLimit(2...4) + } + if let errorMessage { + Section { Text(errorMessage).font(.footnote).foregroundStyle(.red) } + } + } + .navigationTitle("Neue Ausgabe") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Abbrechen") { dismiss() }.disabled(isSaving) + } + ToolbarItem(placement: .confirmationAction) { + if isSaving { ProgressView() } + else { Button("Sichern") { Task { await save() } }.disabled(!canSave) } + } + } + .task { await loadDogs() } + } + } + + private var canSave: Bool { + Double(betrag.replacingOccurrences(of: ",", with: ".")) != nil + } + + private func loadDogs() async { + dogs = (try? await APIClient.shared.get("/api/dogs")) ?? [] + } + + private func save() async { + isSaving = true + errorMessage = nil + defer { isSaving = false } + + guard let betragValue = Double(betrag.replacingOccurrences(of: ",", with: ".")) else { + errorMessage = "Ungültiger Betrag." + return + } + + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + let body = ExpenseCreateBody( + dogId: dogId, + kategorie: kategorie, + betrag: betragValue, + datum: formatter.string(from: date), + notiz: notiz.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : notiz + ) + + do { + let _: Expense = try await APIClient.shared.post("/api/expenses", body: body) + onSaved() + dismiss() + } catch { + errorMessage = error.localizedDescription + } + } +} diff --git a/BanYaroGo/Views/ErsteHilfeView.swift b/BanYaroGo/Views/ErsteHilfeView.swift new file mode 100644 index 0000000..ded3dbf --- /dev/null +++ b/BanYaroGo/Views/ErsteHilfeView.swift @@ -0,0 +1,270 @@ +import SwiftUI + +struct ErsteHilfeView: View { + var body: some View { + List { + Section { + HStack(spacing: 12) { + Image(systemName: "phone.fill") + .foregroundStyle(.red) + VStack(alignment: .leading) { + Text("Im Ernstfall sofort den Tierarzt anrufen!").font(.subheadline.bold()) + Text("Diese Hinweise ersetzen keine tierärztliche Behandlung.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 4) + } + + ForEach(ErsteHilfeContent.topics) { topic in + NavigationLink { + ErsteHilfeDetailView(topic: topic) + } label: { + HStack(spacing: 14) { + Image(systemName: topic.icon) + .font(.title3) + .foregroundStyle(topic.tint) + .frame(width: 32) + VStack(alignment: .leading, spacing: 2) { + Text(topic.title).font(.headline) + Text(topic.summary) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } + } + .padding(.vertical, 4) + } + } + } + .navigationTitle("Erste Hilfe") + .navigationBarTitleDisplayMode(.inline) + } +} + +private struct ErsteHilfeDetailView: View { + let topic: ErsteHilfeTopic + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 18) { + HStack(spacing: 14) { + Image(systemName: topic.icon) + .font(.system(size: 44)) + .foregroundStyle(topic.tint) + .frame(width: 70, height: 70) + .background(topic.tint.opacity(0.15), in: Circle()) + VStack(alignment: .leading, spacing: 4) { + Text(topic.title).font(.title2.bold()) + Text(topic.summary) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + + ForEach(Array(topic.sections.enumerated()), id: \.offset) { _, section in + VStack(alignment: .leading, spacing: 8) { + Text(section.heading).font(.headline) + VStack(alignment: .leading, spacing: 6) { + ForEach(Array(section.steps.enumerated()), id: \.offset) { i, step in + HStack(alignment: .firstTextBaseline, spacing: 10) { + Text("\(i + 1).") + .font(.subheadline.bold()) + .foregroundStyle(topic.tint) + .frame(width: 20, alignment: .trailing) + Text(step) + .font(.subheadline) + } + } + } + } + .padding() + .background(.background.secondary, in: RoundedRectangle(cornerRadius: 12)) + } + + if !topic.warnings.isEmpty { + VStack(alignment: .leading, spacing: 6) { + Label("Sofort zum Tierarzt", systemImage: "exclamationmark.triangle.fill") + .font(.headline) + .foregroundStyle(.red) + ForEach(topic.warnings, id: \.self) { w in + Text("• \(w)").font(.subheadline) + } + } + .padding() + .background(Color.red.opacity(0.08), in: RoundedRectangle(cornerRadius: 12)) + } + } + .padding() + } + .navigationTitle(topic.title) + .navigationBarTitleDisplayMode(.inline) + } +} + +// MARK: - Content + +struct ErsteHilfeTopic: Identifiable { + let id: String + let title: String + let summary: String + let icon: String + let tint: Color + let sections: [Section] + let warnings: [String] + + struct Section { + let heading: String + let steps: [String] + } +} + +enum ErsteHilfeContent { + static let topics: [ErsteHilfeTopic] = [ + ErsteHilfeTopic( + id: "vergiftung", + title: "Vergiftung", + summary: "Schokolade, Trauben, Giftköder, Frostschutz, Medikamente.", + icon: "exclamationmark.octagon.fill", + tint: .red, + sections: [ + .init(heading: "Sofortmaßnahmen", steps: [ + "Hund von der Giftquelle entfernen. Reste sichern (Foto/Verpackung).", + "NICHT zum Erbrechen bringen, außer der Tierarzt sagt es ausdrücklich.", + "Tierarzt oder Tierklinik anrufen — Art und Menge des Gifts angeben.", + "Bei Bewusstlosigkeit: stabile Seitenlage, Atemwege freihalten." + ]), + .init(heading: "Was du nicht tun solltest", steps: [ + "Kein Salzwasser, keine Milch, kein Öl geben — kann gefährlich sein.", + "Nicht abwarten, ob Symptome verschwinden." + ]) + ], + warnings: [ + "Krämpfe, Erbrechen, Durchfall, Speicheln, Apathie", + "Schaum vor dem Maul, Blutungen", + "Verdacht auf Giftköder (Wurst mit Glas, Nägel, Tabletten)" + ] + ), + ErsteHilfeTopic( + id: "hitzschlag", + title: "Hitzschlag", + summary: "Hecheln, Taumeln, Bewusstseinsverlust durch Überhitzung.", + icon: "thermometer.sun.fill", + tint: .orange, + sections: [ + .init(heading: "Sofort kühlen", steps: [ + "Hund in den Schatten oder kühlen Raum bringen.", + "Pfoten, Innenschenkel und Bauch mit lauwarmem (nicht eiskaltem!) Wasser kühlen.", + "Kleine Schlucke Wasser anbieten, nicht zwingen.", + "Auf dem Weg zum Tierarzt weiterkühlen — feuchte Tücher auf den Körper." + ]) + ], + warnings: [ + "Körpertemperatur über 40 °C", + "Verwirrung, Krämpfe, Erbrechen", + "Zahnfleisch dunkelrot, blau oder bleich" + ] + ), + ErsteHilfeTopic( + id: "wunde", + title: "Wunden & Blutungen", + summary: "Schnitte, Bisse, Verletzungen an Pfoten und Körper.", + icon: "bandage.fill", + tint: .pink, + sections: [ + .init(heading: "Bei starker Blutung", steps: [ + "Druckverband mit sauberem Tuch anlegen — fest, aber nicht abschnüren.", + "Bei spritzendem Blut: oberhalb der Wunde mit den Fingern abdrücken.", + "Pfote/Bein hochlagern, ruhig halten.", + "Sofort zum Tierarzt." + ]), + .init(heading: "Kleine Wunden", steps: [ + "Fremdkörper (Glas, Splitter) NICHT selbst rausziehen, wenn tief.", + "Wunde mit klarem Wasser ausspülen.", + "Trocken tupfen, locker abdecken — Hund vom Lecken abhalten." + ]) + ], + warnings: [ + "Stark blutende oder pumpende Wunde", + "Fremdkörper steckt fest", + "Tiefe Biss­wunden (auch wenn klein) → Infektionsgefahr" + ] + ), + ErsteHilfeTopic( + id: "atemnot", + title: "Atemnot & Bewusstlosigkeit", + summary: "Erstickung, Würgen, Kollaps, Reanimation.", + icon: "lungs.fill", + tint: .blue, + sections: [ + .init(heading: "Fremdkörper im Hals", steps: [ + "Maul vorsichtig öffnen, mit Taschenlampe schauen.", + "Sichtbaren Fremdkörper mit den Fingern (nicht Pinzette!) lösen.", + "Bei kleinem Hund: Kopf nach unten halten und zwischen die Schulterblätter klopfen.", + "Bei großem Hund: Heimlich-Manöver — von hinten umfassen, ruckartig nach oben drücken." + ]), + .init(heading: "Reanimation (CPR)", steps: [ + "Hund auf rechte Seite legen, Atemwege kontrollieren.", + "Maul schließen, in die Nase atmen — Brustkorb soll sich heben.", + "30 Herzdruckmassagen (auf Brustkorb-Höhe der Schulterblätter), dann 2 Beatmungen.", + "Weiter bis Atmung einsetzt oder Tierarzt übernimmt." + ]) + ], + warnings: [ + "Bewusstlosigkeit, keine Atmung, kein Puls", + "Blaue Schleimhäute", + "Würgen ohne Erfolg über mehrere Minuten" + ] + ), + ErsteHilfeTopic( + id: "krampfanfall", + title: "Krampfanfall", + summary: "Epileptischer Anfall, Zittern, Bewusstseinsstörung.", + icon: "waveform.path.ecg", + tint: .purple, + sections: [ + .init(heading: "Während des Anfalls", steps: [ + "Ruhe bewahren, Zeit messen (Dauer ist wichtig für den Tierarzt).", + "Umgebung sichern — Möbel/Kanten wegräumen.", + "NICHT festhalten, NICHTS ins Maul stecken.", + "Licht, Geräusche und Reize reduzieren." + ]), + .init(heading: "Nach dem Anfall", steps: [ + "Hund kann verwirrt, blind oder unruhig sein — Zeit geben.", + "Wasser bereitstellen, in eine ruhige Ecke legen.", + "Auch nach erstem Anfall zum Tierarzt — Ursache abklären." + ]) + ], + warnings: [ + "Anfall dauert länger als 5 Minuten (Notfall!)", + "Mehrere Anfälle in Folge", + "Erster Anfall überhaupt" + ] + ), + ErsteHilfeTopic( + id: "magendrehung", + title: "Magendrehung", + summary: "Akuter Notfall, vor allem bei großen Rassen.", + icon: "stomach.fill", + tint: .red, + sections: [ + .init(heading: "Erkennen", steps: [ + "Aufgeblähter, harter Bauch.", + "Erfolgloses Würgen ohne Erbrechen.", + "Unruhe, Speicheln, Atemnot.", + "Häufig nach dem Fressen / Trinken großer Mengen." + ]), + .init(heading: "Sofort handeln", steps: [ + "JEDE Minute zählt — direkt zum Notfall-Tierarzt fahren.", + "Beim Transport ruhig halten, nicht füttern, nicht tränken.", + "Vorher anrufen, damit OP-Team bereitsteht." + ]) + ], + warnings: [ + "Symptome wie oben → IMMER Notfall", + "Ohne OP innerhalb weniger Stunden tödlich" + ] + ) + ] +} diff --git a/BanYaroGo/Views/GassiZeitenView.swift b/BanYaroGo/Views/GassiZeitenView.swift new file mode 100644 index 0000000..b14de0f --- /dev/null +++ b/BanYaroGo/Views/GassiZeitenView.swift @@ -0,0 +1,207 @@ +import SwiftUI +import UserNotifications + +struct GassiZeitenView: View { + @State private var items: [GassiZeit] = [] + @State private var isLoading = false + @State private var errorMessage: String? + @State private var showAdd = false + + private let weekdayLabels: [String: String] = [ + "mo": "Mo", "di": "Di", "mi": "Mi", "do": "Do", + "fr": "Fr", "sa": "Sa", "so": "So" + ] + private let weekdayOrder = ["mo", "di", "mi", "do", "fr", "sa", "so"] + + var body: some View { + content + .navigationTitle("Gassi-Zeiten") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { showAdd = true } label: { Image(systemName: "plus") } + } + } + .sheet(isPresented: $showAdd) { + AddGassiZeitSheet { await load() } + } + .task { + _ = try? await UNUserNotificationCenter.current() + .requestAuthorization(options: [.alert, .sound, .badge]) + await load() + } + .refreshable { await load() } + } + + @ViewBuilder + private var content: some View { + if isLoading && items.isEmpty { + ProgressView() + } else if let errorMessage, items.isEmpty { + ContentUnavailableView("Konnte nicht laden", systemImage: "wifi.slash", description: Text(errorMessage)) + } else if items.isEmpty { + ContentUnavailableView( + "Noch keine Gassi-Zeiten", + systemImage: "alarm", + description: Text("Tippe oben rechts auf +, um regelmäßige Erinnerungen zu setzen.") + ) + } else { + List { + Section { + ForEach(items) { z in + row(z) + } + } footer: { + Text("Erinnerungen kommen lokal vom iPhone — auch ohne Internet.") + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + } + } + + private func row(_ z: GassiZeit) -> some View { + HStack(spacing: 12) { + Image(systemName: "alarm.fill") + .foregroundStyle(z.aktiv == 0 ? Color.secondary : Color.accentColor) + .frame(width: 28) + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Text(z.uhrzeit).font(.headline.monospacedDigit()) + Spacer() + if let dog = z.dogName { + Text(dog).font(.caption).foregroundStyle(.secondary) + } + } + HStack(spacing: 6) { + ForEach(weekdayOrder, id: \.self) { wd in + let active = z.wochentage.contains(wd) + Text(weekdayLabels[wd] ?? wd) + .font(.caption2.bold()) + .foregroundStyle(active ? .white : .secondary) + .frame(width: 22, height: 22) + .background(active ? Color.accentColor : Color.secondary.opacity(0.15), in: Circle()) + } + } + if let n = z.notiz, !n.isEmpty { + Text(n).font(.caption).foregroundStyle(.secondary) + } + } + } + .padding(.vertical, 4) + } + + private func load() async { + isLoading = true + errorMessage = nil + defer { isLoading = false } + do { + items = try await APIClient.shared.get("/api/gassi-zeiten?nur_eigene=true") + // Re-sync local notifications to reflect server state. + for z in items where z.isMine ?? true { + await GassiZeitenScheduler.reschedule(z) + } + } catch { + errorMessage = error.localizedDescription + } + } +} + +private struct AddGassiZeitSheet: View { + @Environment(\.dismiss) private var dismiss + let onSaved: () async -> Void + + @State private var time = Calendar.current.date(bySettingHour: 8, minute: 0, second: 0, of: .now) ?? .now + @State private var selectedDays: Set = ["mo", "di", "mi", "do", "fr"] + @State private var notiz = "" + @State private var dogs: [Dog] = [] + @State private var dogId: Int? + @State private var isSaving = false + @State private var errorMessage: String? + + private let weekdayOrder = ["mo", "di", "mi", "do", "fr", "sa", "so"] + private let weekdayLabels: [String: String] = [ + "mo": "Mo", "di": "Di", "mi": "Mi", "do": "Do", + "fr": "Fr", "sa": "Sa", "so": "So" + ] + + var body: some View { + NavigationStack { + Form { + Section("Uhrzeit") { + DatePicker("Uhrzeit", selection: $time, displayedComponents: .hourAndMinute) + .environment(\.locale, Locale(identifier: "de_DE")) + } + Section("Wochentage") { + HStack(spacing: 8) { + ForEach(weekdayOrder, id: \.self) { wd in + let active = selectedDays.contains(wd) + Button { + if active { selectedDays.remove(wd) } else { selectedDays.insert(wd) } + } label: { + Text(weekdayLabels[wd] ?? wd) + .font(.caption.bold()) + .foregroundStyle(active ? .white : .primary) + .frame(width: 34, height: 34) + .background(active ? Color.accentColor : Color.secondary.opacity(0.15), in: Circle()) + } + .buttonStyle(.plain) + } + } + } + Section("Hund (optional)") { + Picker("Hund", selection: $dogId) { + Text("Ohne Hund").tag(Int?.none) + ForEach(dogs) { d in Text(d.name).tag(Int?.some(d.id)) } + } + } + Section("Notiz (optional)") { + TextField("Notiz", text: $notiz, axis: .vertical) + .lineLimit(2...4) + } + if let errorMessage { + Section { Text(errorMessage).font(.footnote).foregroundStyle(.red) } + } + } + .navigationTitle("Neue Gassi-Zeit") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Abbrechen") { dismiss() }.disabled(isSaving) + } + ToolbarItem(placement: .confirmationAction) { + if isSaving { ProgressView() } + else { Button("Sichern") { Task { await save() } }.disabled(selectedDays.isEmpty) } + } + } + .task { dogs = (try? await APIClient.shared.get("/api/dogs")) ?? [] } + } + } + + private func save() async { + isSaving = true + errorMessage = nil + defer { isSaving = false } + + let f = DateFormatter() + f.dateFormat = "HH:mm" + let body = GassiZeitCreateBody( + dogId: dogId, + wochentage: weekdayOrder.filter { selectedDays.contains($0) }, + uhrzeit: f.string(from: time), + ortName: nil, + lat: nil, + lon: nil, + radiusM: 500, + notiz: notiz.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : notiz + ) + do { + let created: GassiZeit = try await APIClient.shared.post("/api/gassi-zeiten", body: body) + await GassiZeitenScheduler.reschedule(created) + await onSaved() + dismiss() + } catch { + errorMessage = error.localizedDescription + } + } +} diff --git a/BanYaroGo/Views/GiftkoederView.swift b/BanYaroGo/Views/GiftkoederView.swift new file mode 100644 index 0000000..1a3b61a --- /dev/null +++ b/BanYaroGo/Views/GiftkoederView.swift @@ -0,0 +1,203 @@ +import SwiftUI +import MapKit + +struct GiftkoederView: View { + @State private var location = OneShotLocation() + @State private var alerts: [PoisonAlert] = [] + @State private var isLoading = false + @State private var errorMessage: String? + @State private var showReport = false + + var body: some View { + Group { + if let coord = location.coordinate { + content(at: coord) + } else if location.error != nil { + ContentUnavailableView( + "Kein Standort", + systemImage: "location.slash", + description: Text(location.error ?? "") + ) + } else { + ProgressView("Hole Standort…") + } + } + .navigationTitle("Giftköder") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + if location.coordinate != nil { + ToolbarItem(placement: .topBarTrailing) { + Button { + showReport = true + } label: { + Label("Melden", systemImage: "exclamationmark.bubble") + } + } + } + } + .task { location.request() } + .onChange(of: location.coordinate?.latitude) { _, _ in + Task { await load() } + } + .sheet(isPresented: $showReport) { + if let coord = location.coordinate { + ReportPoisonSheet(coord: coord) { await load() } + } + } + } + + private func content(at coord: CLLocationCoordinate2D) -> some View { + VStack(spacing: 0) { + Map(initialPosition: .region(MKCoordinateRegion( + center: coord, + span: MKCoordinateSpan(latitudeDelta: 0.05, longitudeDelta: 0.05) + ))) { + UserAnnotation() + ForEach(alerts) { alert in + Annotation(alert.typ ?? "Giftköder", + coordinate: CLLocationCoordinate2D(latitude: alert.lat, longitude: alert.lon) + ) { + Image(systemName: "exclamationmark.octagon.fill") + .font(.title2) + .foregroundStyle(.red) + .background(.white, in: Circle()) + .shadow(radius: 2) + } + } + } + .frame(height: 260) + .ignoresSafeArea(edges: .top) + + if alerts.isEmpty && !isLoading { + ContentUnavailableView( + "Keine Meldungen im Umkreis", + systemImage: "checkmark.shield", + description: Text("In 5 km Umkreis sind aktuell keine aktiven Warnungen.") + ) + .padding(.top, 30) + Spacer() + } else { + List(alerts) { alert in + PoisonRow(alert: alert) + } + .listStyle(.plain) + } + } + } + + private func load() async { + guard let coord = location.coordinate else { return } + isLoading = true + errorMessage = nil + defer { isLoading = false } + do { + alerts = try await APIClient.shared.get( + "/api/poison?lat=\(coord.latitude)&lon=\(coord.longitude)&radius=5000" + ) + } catch { + errorMessage = error.localizedDescription + } + } +} + +private struct PoisonRow: View { + let alert: PoisonAlert + + var body: some View { + HStack(spacing: 12) { + Image(systemName: "exclamationmark.octagon.fill") + .foregroundStyle(.red) + .frame(width: 28) + VStack(alignment: .leading, spacing: 2) { + HStack { + Text((alert.typ ?? "Unbekannt").capitalized) + .font(.subheadline.bold()) + Spacer() + if let d = alert.distanzM { + Text(distLabel(d)) + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + } + if let b = alert.beschreibung, !b.isEmpty { + Text(b).font(.caption).foregroundStyle(.secondary).lineLimit(2) + } + if let m = alert.melderName { + Text("Gemeldet von \(m)").font(.caption2).foregroundStyle(.tertiary) + } + } + } + .padding(.vertical, 4) + } + + private func distLabel(_ m: Int) -> String { + if m >= 1000 { return String(format: "%.1f km", Double(m) / 1000) } + return "\(m) m" + } +} + +private struct ReportPoisonSheet: View { + @Environment(\.dismiss) private var dismiss + let coord: CLLocationCoordinate2D + let onSaved: () async -> Void + + @State private var typ = "unbekannt" + @State private var beschreibung = "" + @State private var isSaving = false + @State private var errorMessage: String? + + private let typen = ["unbekannt", "wurst", "tabletten", "glas", "metall", "wurfdose", "rattengift"] + + var body: some View { + NavigationStack { + Form { + Section("Standort") { + Text(String(format: "%.5f, %.5f", coord.latitude, coord.longitude)) + .font(.caption.monospacedDigit()) + } + Section("Art") { + Picker("Typ", selection: $typ) { + ForEach(typen, id: \.self) { Text($0.capitalized) } + } + } + Section("Beschreibung (optional)") { + TextField("Was gefunden? Wo genau? Hinweise für andere…", text: $beschreibung, axis: .vertical) + .lineLimit(3...6) + } + if let errorMessage { + Section { Text(errorMessage).font(.footnote).foregroundStyle(.red) } + } + } + .navigationTitle("Giftköder melden") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Abbrechen") { dismiss() }.disabled(isSaving) + } + ToolbarItem(placement: .confirmationAction) { + if isSaving { ProgressView() } + else { Button("Melden") { Task { await save() } } } + } + } + } + } + + private func save() async { + isSaving = true + errorMessage = nil + defer { isSaving = false } + let body = PoisonCreateBody( + lat: coord.latitude, + lon: coord.longitude, + beschreibung: beschreibung.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : beschreibung, + typ: typ + ) + do { + let _: PoisonAlert = try await APIClient.shared.post("/api/poison", body: body) + await onSaved() + dismiss() + } catch { + errorMessage = error.localizedDescription + } + } +} diff --git a/BanYaroGo/Views/LoginView.swift b/BanYaroGo/Views/LoginView.swift index 12ba9f9..198ef16 100644 --- a/BanYaroGo/Views/LoginView.swift +++ b/BanYaroGo/Views/LoginView.swift @@ -73,11 +73,16 @@ struct LoginView: View { VStack(alignment: .leading, spacing: 14) { Divider() feature(icon: "map.fill", title: "Gassi-Touren aufzeichnen", subtitle: "GPS-Tracking auch im Hintergrund — mit Pause, Live Activity und HealthKit-Sync.") + feature(icon: "alarm.fill", title: "Gassi-Zeiten", subtitle: "Tägliche Erinnerungen, damit keine Runde vergessen wird.") feature(icon: "person.2.fill", title: "Hunde-Community", subtitle: "Gassi-Treffen, Tierärzte und Orte in deiner Nähe.") feature(icon: "book.fill", title: "Tagebuch & Impfpass", subtitle: "Alles rund um deinen Hund an einem Ort.") feature(icon: "rosette", title: "Verifizierte Züchter", subtitle: "Züchter-Profile, aktuelle Würfe und Welpen-Vermittlung — kein Hinterhof.") feature(icon: "exclamationmark.shield.fill", title: "Giftköder-Alarm", subtitle: "Warnungen aus deiner Region direkt aufs iPhone.") feature(icon: "house.fill", title: "Hundesitting", subtitle: "Sitter und Sitter-Suche in deiner Nähe — mit Bewertungen.") + feature(icon: "magnifyingglass.circle.fill", title: "Verlorene Hunde", subtitle: "Vermisstmeldungen in deinem Umkreis sehen oder selbst melden.") + feature(icon: "eurosign.circle.fill", title: "Ausgaben tracken", subtitle: "Futter, Tierarzt, Versicherung — alle Hundekosten an einem Ort.") + feature(icon: "cross.case.fill", title: "Erste Hilfe", subtitle: "Notfall-Anleitung für Vergiftung, Hitzschlag, Wunden — komplett offline.") + feature(icon: "cloud.sun.fill", title: "Wetter für Hunde", subtitle: "Vorhersage mit Hitze- und Kältewarnung — was für deinen Hund passt.") } .padding(.horizontal, 18) .padding(.bottom, 18) diff --git a/BanYaroGo/Views/SettingsView.swift b/BanYaroGo/Views/SettingsView.swift index 3725d82..039dbf6 100644 --- a/BanYaroGo/Views/SettingsView.swift +++ b/BanYaroGo/Views/SettingsView.swift @@ -27,6 +27,39 @@ struct SettingsView: View { .padding(.vertical, 4) } + Section("Hund & Alltag") { + NavigationLink { + ErsteHilfeView() + } label: { + Label("Erste Hilfe", systemImage: "cross.case.fill") + } + NavigationLink { + WetterView() + } label: { + Label("Wetter", systemImage: "cloud.sun.fill") + } + NavigationLink { + GassiZeitenView() + } label: { + Label("Gassi-Zeiten", systemImage: "alarm.fill") + } + NavigationLink { + GiftkoederView() + } label: { + Label("Giftköder", systemImage: "exclamationmark.octagon.fill") + } + NavigationLink { + VerloreneHundeView() + } label: { + Label("Verlorene Hunde", systemImage: "magnifyingglass.circle.fill") + } + NavigationLink { + AusgabenView() + } label: { + Label("Ausgaben", systemImage: "eurosign.circle.fill") + } + } + Section("Account") { LabeledContent("Rolle", value: rolleLabel) if auth.profile?.isFounderFlag == true { diff --git a/BanYaroGo/Views/VerloreneHundeView.swift b/BanYaroGo/Views/VerloreneHundeView.swift new file mode 100644 index 0000000..9769bf3 --- /dev/null +++ b/BanYaroGo/Views/VerloreneHundeView.swift @@ -0,0 +1,176 @@ +import SwiftUI +import MapKit + +struct VerloreneHundeView: View { + @State private var location = OneShotLocation() + @State private var lostDogs: [LostDog] = [] + @State private var isLoading = false + @State private var errorMessage: String? + + var body: some View { + content + .navigationTitle("Verlorene Hunde") + .navigationBarTitleDisplayMode(.inline) + .task { + location.request() + await load() + } + .onChange(of: location.coordinate?.latitude) { _, _ in + Task { await load() } + } + .refreshable { await load() } + } + + @ViewBuilder + private var content: some View { + if isLoading && lostDogs.isEmpty { + ProgressView() + } else if let errorMessage, lostDogs.isEmpty { + ContentUnavailableView("Konnte nicht laden", systemImage: "wifi.slash", description: Text(errorMessage)) + } else if lostDogs.isEmpty { + ContentUnavailableView( + "Keine vermissten Hunde", + systemImage: "checkmark.circle", + description: Text("In 25 km Umkreis sind aktuell keine Vermisstmeldungen aktiv.") + ) + } else { + List(lostDogs) { dog in + NavigationLink { + LostDogDetailView(dog: dog) + } label: { + LostDogRow(dog: dog) + } + } + } + } + + private func load() async { + isLoading = true + errorMessage = nil + defer { isLoading = false } + do { + var path = "/api/lost?radius_km=25" + if let coord = location.coordinate { + path = "/api/lost?lat=\(coord.latitude)&lon=\(coord.longitude)&radius_km=25" + } + lostDogs = try await APIClient.shared.get(path) + } catch { + errorMessage = error.localizedDescription + } + } +} + +private struct LostDogRow: View { + let dog: LostDog + + var body: some View { + HStack(spacing: 12) { + avatar + .frame(width: 56, height: 56) + .clipShape(RoundedRectangle(cornerRadius: 8)) + VStack(alignment: .leading, spacing: 2) { + HStack { + Text(dog.name).font(.headline) + Spacer() + if let d = dog.distanzM { + Text(distLabel(d)) + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + } + if let r = dog.rasse, !r.isEmpty { + Text(r).font(.caption).foregroundStyle(.secondary) + } + Text(dog.beschreibung) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } + } + .padding(.vertical, 4) + } + + @ViewBuilder + private var avatar: some View { + if let path = dog.fotoUrl, + let url = URL(string: "https://banyaro.app\(path)") { + AsyncImage(url: url) { phase in + switch phase { + case .success(let img): img.resizable().scaledToFill() + default: placeholder + } + } + } else { + placeholder + } + } + + private var placeholder: some View { + ZStack { + Color.accentColor.opacity(0.15) + Image(systemName: "magnifyingglass") + .foregroundStyle(Color.accentColor) + } + } + + private func distLabel(_ m: Int) -> String { + if m >= 1000 { return String(format: "%.1f km", Double(m) / 1000) } + return "\(m) m" + } +} + +private struct LostDogDetailView: View { + let dog: LostDog + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + if let path = dog.fotoUrl, + let url = URL(string: "https://banyaro.app\(path)") { + AsyncImage(url: url) { phase in + switch phase { + case .success(let img): + img.resizable().scaledToFit() + default: + Rectangle().fill(.gray.opacity(0.15)).frame(height: 200) + } + } + .frame(maxHeight: 280) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + + VStack(alignment: .leading, spacing: 6) { + Text(dog.name).font(.title.bold()) + if let r = dog.rasse, !r.isEmpty { + Text(r).font(.headline).foregroundStyle(.secondary) + } + } + + Text(dog.beschreibung).font(.body) + + Map(initialPosition: .region(MKCoordinateRegion( + center: CLLocationCoordinate2D(latitude: dog.lat, longitude: dog.lon), + span: MKCoordinateSpan(latitudeDelta: 0.05, longitudeDelta: 0.05) + ))) { + Annotation(dog.name, coordinate: CLLocationCoordinate2D(latitude: dog.lat, longitude: dog.lon)) { + Image(systemName: "magnifyingglass.circle.fill") + .font(.title) + .foregroundStyle(.white, Color.accentColor) + .background(.white, in: Circle()) + } + } + .frame(height: 240) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .allowsHitTesting(false) + + if let m = dog.melderName { + Label("Gemeldet von \(m)", systemImage: "person.fill") + .font(.caption).foregroundStyle(.secondary) + } + } + .padding() + } + .navigationTitle("Vermisst") + .navigationBarTitleDisplayMode(.inline) + } +} diff --git a/BanYaroGo/Views/WetterView.swift b/BanYaroGo/Views/WetterView.swift new file mode 100644 index 0000000..3693814 --- /dev/null +++ b/BanYaroGo/Views/WetterView.swift @@ -0,0 +1,172 @@ +import SwiftUI +import CoreLocation + +struct WetterView: View { + @State private var location = OneShotLocation() + @State private var forecast: WeatherForecast? + @State private var isLoading = false + @State private var errorMessage: String? + + 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) + } + } + } else if isLoading { + ProgressView("Lade Wetter…") + } else if let errorMessage { + ContentUnavailableView( + "Wetter konnte nicht geladen werden", + systemImage: "cloud.slash", + description: Text(errorMessage) + ) + } else if location.error != nil { + ContentUnavailableView( + "Kein Standort", + systemImage: "location.slash", + description: Text(location.error ?? "Bitte Standort erlauben.") + ) + } else { + ProgressView("Hole Standort…") + } + } + .navigationTitle("Wetter") + .navigationBarTitleDisplayMode(.inline) + .task { + location.request() + } + .onChange(of: location.coordinate?.latitude) { _, _ in + Task { await load() } + } + .refreshable { await load() } + } + + private func load() async { + guard let coord = location.coordinate else { return } + isLoading = true + errorMessage = nil + defer { isLoading = false } + do { + forecast = try await APIClient.shared.get( + "/api/weather/forecast?lat=\(coord.latitude)&lon=\(coord.longitude)" + ) + } catch { + errorMessage = error.localizedDescription + } + } +} + +private struct WeatherDayRow: View { + let day: WeatherDay + + private var dateLabel: String { + if day.date == today { return "Heute" } + if day.date == tomorrow { return "Morgen" } + return day.wday ?? day.date + } + + 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) + } + + 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 + } +}