Sechs Offline-Features: Erste Hilfe, Ausgaben, Wetter, Gassi-Zeiten, Giftköder, Verlorene
Pitch-Karte erweitert um die neuen Features (sowie Hundesitting, Züchter).
Neue DTOs in DTOs.swift:
- Expense + ExpenseCreateBody
- GassiZeit + GassiZeitCreateBody (mit wochentage [String], radius_m)
- PoisonAlert + PoisonCreateBody
- LostDog + LostDogCreateBody
- WeatherForecast + WeatherDay (mit asphalt_temp, zecken, pollen-Felder)
Neue Views:
- ErsteHilfeView + Detail: sechs Notfall-Topics (Vergiftung, Hitzschlag,
Wunden, Atemnot, Krampfanfall, Magendrehung) — komplett offline, kein API
- AusgabenView: Liste mit Total, AddExpenseSheet mit Kategorie/Betrag/
Datum/Hund-Picker
- WetterView: One-Shot Location + /api/weather/forecast, 7-Tage-Vorhersage
mit Hunde-Tipps (Hitze ab 25°/30°, Frost, Asphalt ≥50°, Zecken, Regen)
- GassiZeitenView: eigene Zeiten + Add-Sheet (Wochentag-Picker, Hund-
Auswahl), automatische lokale UNCalendarNotifications via Scheduler
- GiftkoederView: Map mit Pins + Liste in 5km Umkreis, Report-Sheet mit
Typ-Auswahl
- VerloreneHundeView: Liste mit Foto/Distanz, Detail mit Karte
Support:
- OneShotLocation: kleiner CLLocationManager-Wrapper für einmalige
Positionsabfrage (Wetter, Giftköder)
- GassiZeitenScheduler: UNCalendarNotificationTrigger pro Wochentag,
Identifier-Schema "gz-{id}-{weekday}"
Navigation: Section "Hund & Alltag" im Mehr-Tab mit NavigationLinks zu
allen sechs neuen Ansichten.
This commit is contained in:
parent
f1b3ff4035
commit
68b084be97
11 changed files with 1547 additions and 0 deletions
58
BanYaroGo/Support/GassiZeitenScheduler.swift
Normal file
58
BanYaroGo/Support/GassiZeitenScheduler.swift
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
73
BanYaroGo/Support/OneShotLocation.swift
Normal file
73
BanYaroGo/Support/OneShotLocation.swift
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue