From a2646a18efdcbd3a0eb520a847324a2d05cebc97 Mon Sep 17 00:00:00 2001 From: rene Date: Tue, 2 Jun 2026 19:37:30 +0200 Subject: [PATCH] =?UTF-8?q?1.1:=20Offline-Cache=20+=20Outbox=20f=C3=BCr=20?= =?UTF-8?q?Touren/Tagebuch,=20WeatherKit-Fix,=20Aufr=C3=A4umen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit App-Review-Fix (Guideline 2.1 WeatherKit): - OneShotLocation: deterministisches async resolve() mit 10s-Timeout statt onChange-Lauschen; WetterView lädt bei fehlendem Standort einen Berlin-Fallback → kein ewiges Hängen bei "Hole Standort…", WeatherKit ist immer sichtbar. Offline-Lesen (SwiftData): - CachedRoute/CachedDiaryEntry/CachedImage + CachedAsyncImage: Touren, Tagebuch und Fotos werden cache-first geladen und sind offline verfügbar. - Cache wird bei Logout/401 geleert (RootView), kein Durchschimmern fremder User. Offline-Speichern (Outbox): - PendingRoute/PendingRoutePhoto: Tour inkl. unterwegs hinzugefügter Fotos wird offline lokal gesichert und automatisch hochgeladen (Touren-Tab + App-Start). - Touren-Liste zeigt offline gesicherte Touren mit "wird hochgeladen"-Badge. FinishWalkSheet: - Dismiss-Schutz: Speichern-Dialog lässt sich nicht mehr wegwischen — eine aufgezeichnete Tour geht nicht mehr durch Runterwischen verloren. Wetter: - Ortslabel (Reverse-Geocoding; Fallback "Berlin · Näherung"). - Saubere Offline-Meldung statt rohem networkError. Aufräumen: - Doppeltes "Gassi-Treffen" im Mehr-Tab entfernt. - Veraltete Phase-1/2-Texte neu getextet. - Tote DogsListView gelöscht (Hund-Wechsel läuft über den Heim-Picker). --- BanYaroGo/API/DTOs.swift | 2 +- BanYaroGo/BanYaroGoApp.swift | 6 +- BanYaroGo/Support/OfflineCache.swift | 293 ++++++++++++++++++++++++ BanYaroGo/Support/OneShotLocation.swift | 97 ++++++-- BanYaroGo/Support/Outbox.swift | 112 +++++++++ BanYaroGo/Views/CachedAsyncImage.swift | 51 +++++ BanYaroGo/Views/DiaryDetailView.swift | 12 +- BanYaroGo/Views/DogsListView.swift | 96 -------- BanYaroGo/Views/FinishWalkSheet.swift | 44 +++- BanYaroGo/Views/MainTabView.swift | 8 +- BanYaroGo/Views/RootView.swift | 20 +- BanYaroGo/Views/RouteDetailView.swift | 44 ++-- BanYaroGo/Views/RoutesListView.swift | 88 ++++++- BanYaroGo/Views/SettingsView.swift | 3 +- BanYaroGo/Views/TagebuchView.swift | 29 ++- BanYaroGo/Views/WetterView.swift | 63 ++++- 16 files changed, 769 insertions(+), 199 deletions(-) create mode 100644 BanYaroGo/Support/OfflineCache.swift create mode 100644 BanYaroGo/Support/Outbox.swift create mode 100644 BanYaroGo/Views/CachedAsyncImage.swift delete mode 100644 BanYaroGo/Views/DogsListView.swift diff --git a/BanYaroGo/API/DTOs.swift b/BanYaroGo/API/DTOs.swift index 83a9c65..cc7b733 100644 --- a/BanYaroGo/API/DTOs.swift +++ b/BanYaroGo/API/DTOs.swift @@ -259,7 +259,7 @@ struct DiaryEntry: Decodable, Identifiable { var isMilestoneFlag: Bool { isMilestone == 1 } } -struct DiaryMedia: Decodable, Identifiable { +struct DiaryMedia: Codable, Identifiable { let id: Int let url: String let mediaType: String? diff --git a/BanYaroGo/BanYaroGoApp.swift b/BanYaroGo/BanYaroGoApp.swift index 1225152..813f87a 100644 --- a/BanYaroGo/BanYaroGoApp.swift +++ b/BanYaroGo/BanYaroGoApp.swift @@ -24,7 +24,11 @@ struct BanYaroGoApp: App { } } } - .modelContainer(for: [ActiveWalk.self, PhotoLocation.self]) + .modelContainer(for: [ + ActiveWalk.self, PhotoLocation.self, + CachedRoute.self, CachedDiaryEntry.self, CachedImage.self, + PendingRoute.self, PendingRoutePhoto.self + ]) } private func handleIncoming(url: URL) { diff --git a/BanYaroGo/Support/OfflineCache.swift b/BanYaroGo/Support/OfflineCache.swift new file mode 100644 index 0000000..e009c5f --- /dev/null +++ b/BanYaroGo/Support/OfflineCache.swift @@ -0,0 +1,293 @@ +import Foundation +import SwiftData + +// MARK: - SwiftData-Cache-Modelle +// +// Spiegeln die API-DTOs (RouteListItem/RouteDetail/DiaryEntry), damit Touren +// und Tagebuch offline lesbar sind. Fotos werden separat als CachedImage-Blobs +// gehalten (voll offline). Geleert bei Logout/401 via OfflineCache.clearAll. + +@Model final class CachedRoute { + @Attribute(.unique) var id: Int + var userId: Int + var name: String + var beschreibung: String? + var distanzKm: Double? + var dauerMin: Int? + var createdAt: String? + var userName: String? + var isPublic: Bool + var previewTrackData: Data? // JSON [GPSPoint] + var gpsTrackData: Data? // JSON [GPSPoint] — erst beim Öffnen der Detailansicht gefüllt + var fotoPathsData: Data? // JSON [String] + var cachedAt: Date + + init(from item: RouteListItem) { + id = item.id + userId = item.userId + name = item.name + beschreibung = item.beschreibung + distanzKm = item.distanzKm + dauerMin = item.dauerMin + createdAt = item.createdAt + userName = item.userName + isPublic = item.isPublic ?? false + previewTrackData = CacheJSON.encode(item.previewTrack) + gpsTrackData = nil + fotoPathsData = CacheJSON.encode(item.fotoUrls ?? []) + cachedAt = Date() + } + + func apply(_ item: RouteListItem) { + userId = item.userId + name = item.name + beschreibung = item.beschreibung + distanzKm = item.distanzKm + dauerMin = item.dauerMin + createdAt = item.createdAt + userName = item.userName + isPublic = item.isPublic ?? false + previewTrackData = CacheJSON.encode(item.previewTrack) + fotoPathsData = CacheJSON.encode(item.fotoUrls ?? []) + cachedAt = Date() + } + + func apply(detail: RouteDetail) { + name = detail.name + beschreibung = detail.beschreibung + distanzKm = detail.distanzKm + dauerMin = detail.dauerMin + createdAt = detail.createdAt + userName = detail.userName + gpsTrackData = CacheJSON.encode(detail.gpsTrack) + fotoPathsData = CacheJSON.encode(detail.fotoUrls ?? []) + cachedAt = Date() + } + + var fotoPaths: [String] { CacheJSON.decode([String].self, fotoPathsData) ?? [] } + + func toListItem() -> RouteListItem { + RouteListItem( + id: id, userId: userId, name: name, beschreibung: beschreibung, + distanzKm: distanzKm, dauerMin: dauerMin, createdAt: createdAt, + previewTrack: CacheJSON.decode([GPSPoint].self, previewTrackData) ?? [], + fotoUrls: fotoPaths, userName: userName, isPublic: isPublic + ) + } + + /// Liefert eine Detail-Repräsentation aus dem Cache, sofern bereits ein + /// voller gps_track gespeichert wurde (sonst nil → online nachladen). + func toDetail() -> RouteDetail? { + guard let track: [GPSPoint] = CacheJSON.decode([GPSPoint].self, gpsTrackData), !track.isEmpty else { return nil } + return RouteDetail( + id: id, userId: userId, name: name, beschreibung: beschreibung, + distanzKm: distanzKm, dauerMin: dauerMin, gpsTrack: track, + fotoUrls: fotoPaths, createdAt: createdAt, userName: userName, dogIds: nil + ) + } +} + +@Model final class CachedDiaryEntry { + @Attribute(.unique) var id: Int + var dogId: Int? + var datum: String? + var typ: String? + var titel: String? + var text: String? + var tagsData: Data? + var gpsLat: Double? + var gpsLon: Double? + var locationName: String? + var isMilestone: Int? + var mediaData: Data? // JSON [DiaryMedia] + var createdAt: String? + var cachedAt: Date + + init(from e: DiaryEntry) { + id = e.id + dogId = e.dogId + datum = e.datum + typ = e.typ + titel = e.titel + text = e.text + tagsData = CacheJSON.encode(e.tags ?? []) + gpsLat = e.gpsLat + gpsLon = e.gpsLon + locationName = e.locationName + isMilestone = e.isMilestone + mediaData = CacheJSON.encode(e.mediaItems ?? []) + createdAt = e.createdAt + cachedAt = Date() + } + + func apply(_ e: DiaryEntry) { + dogId = e.dogId + datum = e.datum + typ = e.typ + titel = e.titel + text = e.text + tagsData = CacheJSON.encode(e.tags ?? []) + gpsLat = e.gpsLat + gpsLon = e.gpsLon + locationName = e.locationName + isMilestone = e.isMilestone + mediaData = CacheJSON.encode(e.mediaItems ?? []) + createdAt = e.createdAt + cachedAt = Date() + } + + var mediaPaths: [String] { + (CacheJSON.decode([DiaryMedia].self, mediaData) ?? []).map(\.url) + } + + func toEntry() -> DiaryEntry { + DiaryEntry( + id: id, dogId: dogId, datum: datum, typ: typ, titel: titel, text: text, + tags: CacheJSON.decode([String].self, tagsData), + gpsLat: gpsLat, gpsLon: gpsLon, locationName: locationName, + isMilestone: isMilestone, + mediaItems: CacheJSON.decode([DiaryMedia].self, mediaData), + createdAt: createdAt + ) + } +} + +/// Lokal gespeicherte Bilddaten, keyed über den relativen Medienpfad +/// (z. B. "/media/routes/x.jpg"). `externalStorage` legt große Blobs außerhalb +/// der DB ab. +@Model final class CachedImage { + @Attribute(.unique) var path: String + @Attribute(.externalStorage) var data: Data? + var cachedAt: Date + + init(path: String, data: Data?) { + self.path = path + self.data = data + self.cachedAt = Date() + } +} + +// MARK: - JSON-Helfer + +enum CacheJSON { + private static let encoder = JSONEncoder() + private static let decoder = JSONDecoder() + static func encode(_ value: T) -> Data? { try? encoder.encode(value) } + static func decode(_ type: T.Type, _ data: Data?) -> T? { + guard let data else { return nil } + return try? decoder.decode(type, from: data) + } +} + +// MARK: - Cache-Operationen + +@MainActor +enum OfflineCache { + + static let mediaBase = "https://banyaro.app" + + // — Touren — + + static func upsertRoutes(_ items: [RouteListItem], in ctx: ModelContext) { + for item in items { + let rid = item.id + if let existing = try? ctx.fetch(FetchDescriptor(predicate: #Predicate { $0.id == rid })).first { + existing.apply(item) + } else { + ctx.insert(CachedRoute(from: item)) + } + } + try? ctx.save() + } + + static func cachedRoutes(in ctx: ModelContext) -> [RouteListItem] { + let desc = FetchDescriptor(sortBy: [SortDescriptor(\.createdAt, order: .reverse)]) + let cached = (try? ctx.fetch(desc)) ?? [] + return cached.map { $0.toListItem() } + } + + static func upsertRouteDetail(_ detail: RouteDetail, in ctx: ModelContext) { + let rid = detail.id + if let existing = try? ctx.fetch(FetchDescriptor(predicate: #Predicate { $0.id == rid })).first { + existing.apply(detail: detail) + } else { + // Kommt vom Detail ohne vorherigen Listeneintrag: minimal anlegen. + let stub = RouteListItem( + id: detail.id, userId: detail.userId, name: detail.name, + beschreibung: detail.beschreibung, distanzKm: detail.distanzKm, + dauerMin: detail.dauerMin, createdAt: detail.createdAt, + previewTrack: detail.gpsTrack, fotoUrls: detail.fotoUrls, + userName: detail.userName, isPublic: nil + ) + let route = CachedRoute(from: stub) + route.apply(detail: detail) + ctx.insert(route) + } + try? ctx.save() + } + + static func cachedRouteDetail(id: Int, in ctx: ModelContext) -> RouteDetail? { + try? ctx.fetch(FetchDescriptor(predicate: #Predicate { $0.id == id })).first?.toDetail() + } + + // — Tagebuch — + + static func upsertDiary(_ entries: [DiaryEntry], in ctx: ModelContext) { + for e in entries { + let eid = e.id + if let existing = try? ctx.fetch(FetchDescriptor(predicate: #Predicate { $0.id == eid })).first { + existing.apply(e) + } else { + ctx.insert(CachedDiaryEntry(from: e)) + } + } + try? ctx.save() + } + + static func cachedDiary(dogId: Int, in ctx: ModelContext) -> [DiaryEntry] { + let desc = FetchDescriptor( + predicate: #Predicate { $0.dogId == dogId }, + sortBy: [SortDescriptor(\.datum, order: .reverse)] + ) + let cached = (try? ctx.fetch(desc)) ?? [] + return cached.map { $0.toEntry() } + } + + // — Bilder — + + static func imageData(path: String, in ctx: ModelContext) -> Data? { + (try? ctx.fetch(FetchDescriptor(predicate: #Predicate { $0.path == path })).first)?.data + } + + static func storeImage(path: String, data: Data, in ctx: ModelContext) { + if let existing = try? ctx.fetch(FetchDescriptor(predicate: #Predicate { $0.path == path })).first { + existing.data = data + existing.cachedAt = Date() + } else { + ctx.insert(CachedImage(path: path, data: data)) + } + try? ctx.save() + } + + /// Lädt fehlende Foto-Pfade herunter und legt sie lokal ab (für volles Offline). + static func prefetchImages(paths: [String], in ctx: ModelContext) async { + for path in paths where !path.isEmpty { + let exists = (try? ctx.fetch(FetchDescriptor(predicate: #Predicate { $0.path == path })).first) != nil + if exists { continue } + guard let url = URL(string: mediaBase + path) else { continue } + if let (data, _) = try? await URLSession.shared.data(from: url) { + storeImage(path: path, data: data, in: ctx) + } + } + } + + // — Aufräumen — + + /// Komplett leeren — bei Logout/401, damit kein fremder User-Cache durchschlägt. + static func clearAll(in ctx: ModelContext) { + try? ctx.delete(model: CachedRoute.self) + try? ctx.delete(model: CachedDiaryEntry.self) + try? ctx.delete(model: CachedImage.self) + try? ctx.save() + } +} diff --git a/BanYaroGo/Support/OneShotLocation.swift b/BanYaroGo/Support/OneShotLocation.swift index 0aa9794..cc3c27b 100644 --- a/BanYaroGo/Support/OneShotLocation.swift +++ b/BanYaroGo/Support/OneShotLocation.swift @@ -3,11 +3,26 @@ 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. +/// Wetter und Giftköder, die eine Position ohne das volle Tracking-Setup +/// brauchen. +/// +/// Zwei Wege: +/// - `resolve()` (async): liefert die Koordinate **oder nil** nach Fix, Fehler, +/// verweigerter Berechtigung oder **Timeout**. Deterministisch — keine +/// Abhängigkeit von SwiftUI-`onChange`-Zustandswechseln. +/// - `request()` (observable): füllt `coordinate`/`error`/`isResolving` für +/// Views, die den Zustand direkt beobachten. +/// +/// Der Timeout verhindert, dass ein Screen ewig bei „Hole Standort…" hängt, +/// wenn CoreLocation (z. B. auf einem Review-iPad ohne Position) weder Fix noch +/// Fehler liefert. @Observable @MainActor final class OneShotLocation: NSObject, CLLocationManagerDelegate { private let manager = CLLocationManager() + private var timeoutTask: Task? + private let timeoutSeconds: Double = 10 + private var continuation: CheckedContinuation? var coordinate: CLLocationCoordinate2D? var error: String? @@ -19,20 +34,61 @@ final class OneShotLocation: NSObject, CLLocationManagerDelegate { manager.desiredAccuracy = kCLLocationAccuracyHundredMeters } + /// Beobachtbarer Weg (Giftköder): startet die Auflösung, Ergebnis landet in + /// `coordinate`/`error`. func request() { + isResolving = true + Task { _ = await resolve() } + } + + /// Async-Weg (Wetter): wartet auf Fix/Fehler/Timeout und liefert die + /// Koordinate oder nil. Aufrufer kann danach z. B. einen Fallback nutzen. + func resolve() async -> CLLocationCoordinate2D? { + if let coordinate { return coordinate } + // Schon eine Auflösung im Gange? Nicht doppelt starten (Continuation-Leak). + if continuation != nil { return nil } + 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 + return await withCheckedContinuation { (cont: CheckedContinuation) in + self.continuation = cont + self.startTimeout() + switch self.manager.authorizationStatus { + case .notDetermined: + self.manager.requestWhenInUseAuthorization() + case .denied, .restricted: + self.complete(coord: nil, errorText: "Standortzugriff verweigert.") + case .authorizedWhenInUse, .authorizedAlways: + self.manager.requestLocation() + @unknown default: + self.complete(coord: nil, errorText: "Unbekannter Standort-Status.") + } + } + } + + private func startTimeout() { + timeoutTask?.cancel() + let seconds = timeoutSeconds + timeoutTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(seconds)) + guard let self, !Task.isCancelled else { return } + if self.continuation != nil { + self.complete(coord: nil, errorText: "Standort nicht ermittelbar (Zeitüberschreitung).") + } + } + } + + /// Einziger Endpunkt für jeden Ausgang. Resümiert die Continuation genau + /// einmal und aktualisiert den beobachtbaren Zustand. + private func complete(coord: CLLocationCoordinate2D?, errorText: String?) { + timeoutTask?.cancel() + timeoutTask = nil + if let coord { coordinate = coord } + if coord == nil, let errorText { error = errorText } + isResolving = false + if let cont = continuation { + continuation = nil + cont.resume(returning: coord ?? coordinate) } } @@ -40,31 +96,24 @@ final class OneShotLocation: NSObject, CLLocationManagerDelegate { _ 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 - } + guard let c = locations.first?.coordinate else { return } + Task { @MainActor in self.complete(coord: c, errorText: nil) } } nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError err: Error) { let msg = err.localizedDescription - Task { @MainActor in - self.error = msg - self.isResolving = false - } + Task { @MainActor in self.complete(coord: nil, errorText: msg) } } nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { let status = manager.authorizationStatus Task { @MainActor in + guard self.continuation != nil else { return } switch status { case .authorizedWhenInUse, .authorizedAlways: manager.requestLocation() case .denied, .restricted: - self.error = "Standortzugriff verweigert." - self.isResolving = false + self.complete(coord: nil, errorText: "Standortzugriff verweigert.") default: break } diff --git a/BanYaroGo/Support/Outbox.swift b/BanYaroGo/Support/Outbox.swift new file mode 100644 index 0000000..0f7bd77 --- /dev/null +++ b/BanYaroGo/Support/Outbox.swift @@ -0,0 +1,112 @@ +import Foundation +import SwiftData + +// MARK: - Outbox: offline gesicherte Touren +// +// Wird eine Tour ohne Internet gespeichert, landet sie als PendingRoute (inkl. +// unterwegs hinzugefügter Fotos als Blobs) lokal in SwiftData und wird per +// OfflineCache.syncPendingRoutes hochgeladen, sobald wieder Netz da ist. + +@Model final class PendingRoute { + @Attribute(.unique) var localId: UUID + var name: String + var gpsTrackData: Data? // JSON [GPSPoint] + var distanzKm: Double + var dauerMin: Int + var dogIdsData: Data? // JSON [Int] + var isPublic: Bool + var createdAt: Date + @Relationship(deleteRule: .cascade, inverse: \PendingRoutePhoto.route) + var photos: [PendingRoutePhoto] + + init(body: RouteCreateBody) { + localId = UUID() + name = body.name + gpsTrackData = CacheJSON.encode(body.gpsTrack) + distanzKm = body.distanzKm + dauerMin = body.dauerMin + dogIdsData = CacheJSON.encode(body.dogIds) + isPublic = body.isPublic + createdAt = Date() + photos = [] + } + + var gpsTrack: [GPSPoint] { CacheJSON.decode([GPSPoint].self, gpsTrackData) ?? [] } + var dogIds: [Int] { CacheJSON.decode([Int].self, dogIdsData) ?? [] } + + func toCreateBody() -> RouteCreateBody { + RouteCreateBody( + name: name, gpsTrack: gpsTrack, distanzKm: distanzKm, + dauerMin: dauerMin, dogIds: dogIds, isPublic: isPublic + ) + } +} + +@Model final class PendingRoutePhoto { + var localId: UUID + @Attribute(.externalStorage) var data: Data + var lat: Double? + var lon: Double? + var order: Int + var route: PendingRoute? + + init(data: Data, lat: Double?, lon: Double?, order: Int) { + localId = UUID() + self.data = data + self.lat = lat + self.lon = lon + self.order = order + } +} + +// MARK: - Outbox-Operationen + +extension OfflineCache { + + /// Sichert eine Tour (mit unterwegs hinzugefügten Fotos) offline lokal. + static func savePendingRoute(body: RouteCreateBody, photos: [CapturedPhoto], in ctx: ModelContext) { + let pending = PendingRoute(body: body) + ctx.insert(pending) + for (i, p) in photos.enumerated() { + let photo = PendingRoutePhoto(data: p.data, lat: p.location?.lat, lon: p.location?.lon, order: i) + photo.route = pending + ctx.insert(photo) + } + try? ctx.save() + } + + static func pendingRoutesCount(in ctx: ModelContext) -> Int { + (try? ctx.fetchCount(FetchDescriptor())) ?? 0 + } + + /// Lädt alle offline gesicherten Touren (inkl. Fotos) hoch. Erfolgreiche + /// werden lokal gelöscht. Schlägt der Upload fehl (weiter offline), bleibt + /// die Tour liegen und wird beim nächsten Trigger erneut versucht. + @discardableResult + static func syncPendingRoutes(in ctx: ModelContext) async -> Int { + let pending = (try? ctx.fetch( + FetchDescriptor(sortBy: [SortDescriptor(\.createdAt, order: .forward)]) + )) ?? [] + var uploaded = 0 + for route in pending { + do { + let created: RouteDetail = try await APIClient.shared.post("/api/routes", body: route.toCreateBody()) + for photo in route.photos.sorted(by: { $0.order < $1.order }) { + let resized = ImageResize.resizedJPEG(from: photo.data) + _ = try? await APIClient.shared.uploadFile( + "/api/routes/\(created.id)/photo", + filename: "photo_\(photo.order + 1).jpg", + data: resized + ) + } + ctx.delete(route) // cascade löscht die Fotos mit + try? ctx.save() + uploaded += 1 + } catch { + // Weiter offline oder Serverfehler → liegen lassen, später erneut. + break + } + } + return uploaded + } +} diff --git a/BanYaroGo/Views/CachedAsyncImage.swift b/BanYaroGo/Views/CachedAsyncImage.swift new file mode 100644 index 0000000..0b2450e --- /dev/null +++ b/BanYaroGo/Views/CachedAsyncImage.swift @@ -0,0 +1,51 @@ +import SwiftUI +import UIKit +import SwiftData + +/// Wie `AsyncImage`, aber offline-fähig: zeigt zuerst ein lokal gecachtes Bild +/// (CachedImage in SwiftData); fehlt es, wird es remote geladen und dabei gleich +/// in den Offline-Cache geschrieben. `path` ist der relative Medienpfad +/// (z. B. "/media/routes/x.jpg"), die Basis-URL hängt OfflineCache an. +struct CachedAsyncImage: View { + private let path: String? + private let content: (Image) -> Content + private let placeholder: () -> Placeholder + + @Environment(\.modelContext) private var ctx + @State private var uiImage: UIImage? + + init( + path: String?, + @ViewBuilder content: @escaping (Image) -> Content, + @ViewBuilder placeholder: @escaping () -> Placeholder + ) { + self.path = path + self.content = content + self.placeholder = placeholder + } + + var body: some View { + Group { + if let uiImage { + content(Image(uiImage: uiImage)) + } else { + placeholder() + } + } + .task(id: path) { await load() } + } + + private func load() async { + uiImage = nil + guard let path, !path.isEmpty else { return } + if let data = OfflineCache.imageData(path: path, in: ctx), let img = UIImage(data: data) { + uiImage = img + return + } + guard let url = URL(string: OfflineCache.mediaBase + path) else { return } + if let (data, _) = try? await URLSession.shared.data(from: url), let img = UIImage(data: data) { + uiImage = img + OfflineCache.storeImage(path: path, data: data, in: ctx) + } + } +} diff --git a/BanYaroGo/Views/DiaryDetailView.swift b/BanYaroGo/Views/DiaryDetailView.swift index 35b2714..9083392 100644 --- a/BanYaroGo/Views/DiaryDetailView.swift +++ b/BanYaroGo/Views/DiaryDetailView.swift @@ -91,14 +91,10 @@ struct DiaryDetailView: View { VStack(spacing: 8) { TabView(selection: $photoIndex) { ForEach(Array(media.enumerated()), id: \.element.id) { idx, m in - AsyncImage(url: URL(string: "https://banyaro.app\(m.url)")) { phase in - switch phase { - case .success(let img): img.resizable().scaledToFit() - case .failure: Image(systemName: "photo") - .font(.largeTitle) - .foregroundStyle(.secondary) - default: ProgressView() - } + CachedAsyncImage(path: m.url) { img in + img.resizable().scaledToFit() + } placeholder: { + ProgressView() } .frame(maxWidth: .infinity) .tag(idx) diff --git a/BanYaroGo/Views/DogsListView.swift b/BanYaroGo/Views/DogsListView.swift deleted file mode 100644 index 087a440..0000000 --- a/BanYaroGo/Views/DogsListView.swift +++ /dev/null @@ -1,96 +0,0 @@ -import SwiftUI - -struct DogsListView: View { - @State private var dogs: [Dog] = [] - @State private var isLoading = false - @State private var errorMessage: String? - - var body: some View { - NavigationStack { - content - .navigationTitle("Hunde") - .task { await load() } - } - } - - @ViewBuilder - private var content: some View { - if isLoading && dogs.isEmpty { - ProgressView() - } else if let error = errorMessage, dogs.isEmpty { - ContentUnavailableView( - "Konnte Hunde nicht laden", - systemImage: "wifi.slash", - description: Text(error) - ) - } else if dogs.isEmpty { - ContentUnavailableView( - "Keine Hunde", - systemImage: "pawprint", - description: Text("Lege deinen ersten Hund in der PWA an.") - ) - } else { - List(dogs) { dog in - DogRow(dog: dog) - } - .refreshable { await load() } - } - } - - private func load() async { - isLoading = true - errorMessage = nil - defer { isLoading = false } - do { - dogs = try await APIClient.shared.get("/api/dogs") - } catch { - errorMessage = error.localizedDescription - } - } -} - -struct DogRow: View { - let dog: Dog - - var body: some View { - HStack(spacing: 12) { - avatar - .frame(width: 56, height: 56) - .background(.background.secondary) - .clipShape(Circle()) - - VStack(alignment: .leading, spacing: 2) { - Text(dog.name).font(.headline) - if let rasse = dog.rasse, !rasse.isEmpty { - Text(rasse) - .font(.subheadline) - .foregroundStyle(.secondary) - } - } - } - .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 { - Image(systemName: "pawprint.fill") - .font(.title2) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } -} diff --git a/BanYaroGo/Views/FinishWalkSheet.swift b/BanYaroGo/Views/FinishWalkSheet.swift index 2f70660..3d6f7f7 100644 --- a/BanYaroGo/Views/FinishWalkSheet.swift +++ b/BanYaroGo/Views/FinishWalkSheet.swift @@ -25,6 +25,7 @@ struct FinishWalkSheet: View { @State private var saveState: SaveState = .idle @State private var errorMessage: String? + @State private var savedOffline = false private enum SaveState: Equatable { case idle @@ -144,7 +145,15 @@ struct FinishWalkSheet: View { .onChange(of: photoSelection) { _, newItems in Task { await loadPhotos(from: newItems) } } - .interactiveDismissDisabled(saveState != .idle) + // Immer blockieren: eine aufgezeichnete Tour darf nicht durch + // versehentliches Runterwischen verloren gehen — nur „Speichern" + // oder „Verwerfen" beenden das Sheet. + .interactiveDismissDisabled(true) + .alert("Offline gespeichert", isPresented: $savedOffline) { + Button("OK") { onSaved(); dismiss() } + } message: { + Text("Keine Internetverbindung. Die Tour ist lokal gesichert und wird automatisch hochgeladen, sobald du wieder online bist.") + } } } @@ -283,6 +292,15 @@ struct FinishWalkSheet: View { do { route = try await APIClient.shared.post("/api/routes", body: body) } catch { + // Transportfehler (offline) → Tour inkl. Fotos lokal in die Outbox, + // wird automatisch hochgeladen, sobald wieder Netz da ist. + if error is URLError { + OfflineCache.savePendingRoute(body: body, photos: photoData, in: modelContext) + await syncHealthIfEnabled() + saveState = .idle + savedOffline = true + return + } errorMessage = error.localizedDescription saveState = .idle return @@ -321,19 +339,21 @@ struct FinishWalkSheet: View { saveState = .uploadingPhotos(done: photoData.count, total: photoData.count) } - // Apple Health sync (only if user opted in) - if healthKitSyncEnabled, points.count >= 2 { - let endedAt = Date.now - let startedAt = endedAt.addingTimeInterval(-Double(durationSeconds)) - await WalkHealthSync.shared.saveWalk( - points: points, - startedAt: startedAt, - endedAt: endedAt, - distanceMeters: distanceMeters - ) - } + await syncHealthIfEnabled() onSaved() dismiss() } + + private func syncHealthIfEnabled() async { + guard healthKitSyncEnabled, points.count >= 2 else { return } + let endedAt = Date.now + let startedAt = endedAt.addingTimeInterval(-Double(durationSeconds)) + await WalkHealthSync.shared.saveWalk( + points: points, + startedAt: startedAt, + endedAt: endedAt, + distanceMeters: distanceMeters + ) + } } diff --git a/BanYaroGo/Views/MainTabView.swift b/BanYaroGo/Views/MainTabView.swift index 02e52e5..40b0075 100644 --- a/BanYaroGo/Views/MainTabView.swift +++ b/BanYaroGo/Views/MainTabView.swift @@ -1,7 +1,9 @@ import SwiftUI +import SwiftData struct MainTabView: View { @Environment(AuthSession.self) private var auth + @Environment(\.modelContext) private var ctx var body: some View { TabView { @@ -17,6 +19,10 @@ struct MainTabView: View { SettingsView() .tabItem { Label("Mehr", systemImage: "person.crop.circle") } } - .task { await auth.loadProfile() } + .task { + await auth.loadProfile() + // Offline gesicherte Touren beim Start hochladen (falls online). + await OfflineCache.syncPendingRoutes(in: ctx) + } } } diff --git a/BanYaroGo/Views/RootView.swift b/BanYaroGo/Views/RootView.swift index fced642..a58e589 100644 --- a/BanYaroGo/Views/RootView.swift +++ b/BanYaroGo/Views/RootView.swift @@ -1,13 +1,25 @@ import SwiftUI +import SwiftData struct RootView: View { @Environment(AuthSession.self) private var auth + @Environment(\.modelContext) private var ctx var body: some View { - if auth.isLoggedIn { - MainTabView() - } else { - LoginView() + Group { + if auth.isLoggedIn { + MainTabView() + } else { + LoginView() + } + } + // Offline-Cache leeren, sobald der User wechselt (Logout oder 401), + // damit nie Touren/Tagebuch/Fotos eines vorigen Users durchschimmern. + .onReceive(NotificationCenter.default.publisher(for: .userDidLogout)) { _ in + OfflineCache.clearAll(in: ctx) + } + .onReceive(NotificationCenter.default.publisher(for: .apiUnauthorized)) { _ in + OfflineCache.clearAll(in: ctx) } } } diff --git a/BanYaroGo/Views/RouteDetailView.swift b/BanYaroGo/Views/RouteDetailView.swift index 7d7a1f9..67170b0 100644 --- a/BanYaroGo/Views/RouteDetailView.swift +++ b/BanYaroGo/Views/RouteDetailView.swift @@ -8,6 +8,7 @@ struct RouteDetailView: View { @Environment(\.dismiss) private var dismiss @Environment(AuthSession.self) private var auth + @Environment(\.modelContext) private var ctx @Query private var allPhotoLocations: [PhotoLocation] @State private var detail: RouteDetail? @@ -218,27 +219,32 @@ struct RouteDetailView: View { } private func photoThumb(_ path: String) -> some View { - let url = URL(string: "https://banyaro.app\(path)") - return AsyncImage(url: url) { phase in - switch phase { - case .success(let img): - img.resizable().scaledToFill() - default: - Rectangle().fill(.gray.opacity(0.15)) - } + CachedAsyncImage(path: path) { img in + img.resizable().scaledToFill() + } placeholder: { + Rectangle().fill(.gray.opacity(0.15)) } .frame(width: 160, height: 160) .clipShape(RoundedRectangle(cornerRadius: 10)) } private func load() async { - isLoading = true + // 1) Cache-first: gespeicherten Detail-Stand zeigen (auch offline) + if detail == nil, let cached = OfflineCache.cachedRouteDetail(id: routeId, in: ctx) { + detail = cached + } + isLoading = (detail == nil) errorMessage = nil defer { isLoading = false } + // 2) Netzwerk → Cache aktualisieren; bei Fehler bleibt der Cache stehen do { - detail = try await APIClient.shared.get("/api/routes/\(routeId)") + let fetched: RouteDetail = try await APIClient.shared.get("/api/routes/\(routeId)") + detail = fetched + OfflineCache.upsertRouteDetail(fetched, in: ctx) + let paths = fetched.fotoUrls ?? [] + Task { await OfflineCache.prefetchImages(paths: paths, in: ctx) } } catch { - errorMessage = error.localizedDescription + if detail == nil { errorMessage = error.localizedDescription } } } @@ -307,17 +313,11 @@ private struct PhotoViewerSheet: View { NavigationStack { ZStack { Color.black.ignoresSafeArea() - if let url = URL(string: "https://banyaro.app\(path)") { - AsyncImage(url: url) { phase in - switch phase { - case .success(let img): - img.resizable().scaledToFit() - case .failure: - ContentUnavailableView("Foto nicht ladbar", systemImage: "photo.badge.exclamationmark") - .foregroundStyle(.white) - default: - ProgressView().tint(.white) - } + if !path.isEmpty { + CachedAsyncImage(path: path) { img in + img.resizable().scaledToFit() + } placeholder: { + ProgressView().tint(.white) } } } diff --git a/BanYaroGo/Views/RoutesListView.swift b/BanYaroGo/Views/RoutesListView.swift index dedf87c..3f4734e 100644 --- a/BanYaroGo/Views/RoutesListView.swift +++ b/BanYaroGo/Views/RoutesListView.swift @@ -1,6 +1,9 @@ import SwiftUI +import SwiftData struct RoutesListView: View { + @Environment(\.modelContext) private var ctx + @Query(sort: \PendingRoute.createdAt, order: .reverse) private var pending: [PendingRoute] @State private var routes: [RouteListItem] = [] @State private var isLoading = false @State private var errorMessage: String? @@ -15,26 +18,39 @@ struct RoutesListView: View { @ViewBuilder private var content: some View { - if isLoading && routes.isEmpty { + if isLoading && routes.isEmpty && pending.isEmpty { ProgressView() - } else if let error = errorMessage, routes.isEmpty { + } else if let error = errorMessage, routes.isEmpty && pending.isEmpty { ContentUnavailableView( "Konnte Touren nicht laden", systemImage: "wifi.slash", description: Text(error) ) - } else if routes.isEmpty { + } else if routes.isEmpty && pending.isEmpty { ContentUnavailableView( "Keine Touren", systemImage: "map", - description: Text("Lege deine erste Gassi-Tour in der PWA an — oder warte auf Phase 2.") + description: Text("Zeichne deine erste Gassi-Tour über den Tab „Aufnehmen“ auf — oder importiere einen GPX-Track.") ) } else { - List(routes) { route in - NavigationLink { - RouteDetailView(routeId: route.id, fallbackName: route.name) - } label: { - RouteRowView(route: route) + List { + if !pending.isEmpty { + Section { + ForEach(pending) { p in + PendingRouteRow(route: p) + } + } header: { + Label("Offline – wird hochgeladen", systemImage: "icloud.and.arrow.up") + } + } + Section { + ForEach(routes) { route in + NavigationLink { + RouteDetailView(routeId: route.id, fallbackName: route.name) + } label: { + RouteRowView(route: route) + } + } } } .refreshable { await load() } @@ -42,13 +58,25 @@ struct RoutesListView: View { } private func load() async { - isLoading = true + // 1) Cache-first: sofort anzeigen (auch offline) + if routes.isEmpty { + let cached = OfflineCache.cachedRoutes(in: ctx) + if !cached.isEmpty { routes = cached } + } + isLoading = routes.isEmpty errorMessage = nil defer { isLoading = false } + // 2) Offline gesicherte Touren hochladen (falls online) + await OfflineCache.syncPendingRoutes(in: ctx) + // 3) Netzwerk → Cache aktualisieren; bei Fehler bleibt der Cache stehen do { - routes = try await APIClient.shared.get("/api/routes") + let fetched: [RouteListItem] = try await APIClient.shared.get("/api/routes") + routes = fetched + OfflineCache.upsertRoutes(fetched, in: ctx) + let paths = fetched.flatMap { $0.fotoUrls ?? [] } + Task { await OfflineCache.prefetchImages(paths: paths, in: ctx) } } catch { - errorMessage = error.localizedDescription + if routes.isEmpty { errorMessage = error.localizedDescription } } } } @@ -90,6 +118,42 @@ struct RouteRowView: View { } } +struct PendingRouteRow: View { + let route: PendingRoute + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(route.name) + .font(.headline) + Spacer() + Image(systemName: "icloud.and.arrow.up") + .foregroundStyle(.orange) + } + HStack(spacing: 12) { + Text(String(format: "%.1f km", route.distanzKm)) + Text("\(route.dauerMin) min") + if !route.photos.isEmpty { + Label("\(route.photos.count)", systemImage: "photo") + } + Spacer() + Text("noch nicht hochgeladen") + .foregroundStyle(.orange) + } + .font(.caption) + .foregroundStyle(.secondary) + + let track = route.gpsTrack + if track.count >= 2 { + MiniRouteMap(track: track) + .frame(height: 110) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + } + .padding(.vertical, 4) + } +} + enum DateUtil { /// Parses backend timestamps (SQLite `YYYY-MM-DD HH:MM:SS` or ISO-8601) /// into a German short date. diff --git a/BanYaroGo/Views/SettingsView.swift b/BanYaroGo/Views/SettingsView.swift index 5438e79..3a16d43 100644 --- a/BanYaroGo/Views/SettingsView.swift +++ b/BanYaroGo/Views/SettingsView.swift @@ -101,7 +101,6 @@ struct SettingsView: View { Section("Mehr auf banyaro.app") { pwaLink("Forum", systemImage: "bubble.left.and.bubble.right.fill", fragment: "forum") pwaLink("Hunde-Profile bearbeiten", systemImage: "pawprint.fill", fragment: "dogs") - pwaLink("Gassi-Treffen", systemImage: "person.2.fill", fragment: "walks") pwaLink("Profil & Einstellungen", systemImage: "gearshape.fill", fragment: "settings") } @@ -170,7 +169,7 @@ struct SettingsView: View { } Section("Über") { - Text("Ban Yaro Go ist die native iOS-Ergänzung zur banyaro.app PWA. Phase 1: deine Touren ansehen.") + Text("Ban Yaro Go ist die native iOS-Ergänzung zur banyaro.app — fürs Gassi-Tracking, Wetter, Tagebuch und mehr unterwegs.") .font(.footnote) .foregroundStyle(.secondary) } diff --git a/BanYaroGo/Views/TagebuchView.swift b/BanYaroGo/Views/TagebuchView.swift index ab8138e..62f89f5 100644 --- a/BanYaroGo/Views/TagebuchView.swift +++ b/BanYaroGo/Views/TagebuchView.swift @@ -1,7 +1,9 @@ import SwiftUI +import SwiftData struct TagebuchView: View { @Environment(ActiveDogStore.self) private var activeDog + @Environment(\.modelContext) private var ctx @State private var entries: [DiaryEntry] = [] @State private var isLoading = false @@ -66,16 +68,26 @@ struct TagebuchView: View { private func load() async { guard let dog = activeDog.activeDog else { return } - isLoading = true + // 1) Cache-first: sofort anzeigen (auch offline) + if entries.isEmpty { + let cached = OfflineCache.cachedDiary(dogId: dog.id, in: ctx) + if !cached.isEmpty { entries = cached } + } + isLoading = entries.isEmpty errorMessage = nil defer { isLoading = false } + // 2) Netzwerk → Cache aktualisieren; bei Fehler bleibt der Cache stehen do { - entries = try await APIClient.shared.get("/api/dogs/\(dog.id)/diary?limit=50") + let fetched: [DiaryEntry] = try await APIClient.shared.get("/api/dogs/\(dog.id)/diary?limit=50") + entries = fetched + OfflineCache.upsertDiary(fetched, in: ctx) + let paths = fetched.flatMap { ($0.mediaItems ?? []).map(\.url) } + Task { await OfflineCache.prefetchImages(paths: paths, in: ctx) } } catch let decodingError as DecodingError { - errorMessage = Self.describe(decodingError) + if entries.isEmpty { errorMessage = Self.describe(decodingError) } print("Tagebuch decode error: \(decodingError)") } catch { - errorMessage = error.localizedDescription + if entries.isEmpty { errorMessage = error.localizedDescription } } } @@ -131,11 +143,10 @@ private struct DiaryRow: View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 6) { ForEach(media) { m in - AsyncImage(url: URL(string: "https://banyaro.app\(m.url)")) { phase in - switch phase { - case .success(let img): img.resizable().scaledToFill() - default: Rectangle().fill(.gray.opacity(0.15)) - } + CachedAsyncImage(path: m.url) { img in + img.resizable().scaledToFill() + } placeholder: { + Rectangle().fill(.gray.opacity(0.15)) } .frame(width: 80, height: 80) .clipShape(RoundedRectangle(cornerRadius: 8)) diff --git a/BanYaroGo/Views/WetterView.swift b/BanYaroGo/Views/WetterView.swift index e04aec8..d8b618a 100644 --- a/BanYaroGo/Views/WetterView.swift +++ b/BanYaroGo/Views/WetterView.swift @@ -8,6 +8,16 @@ struct WetterView: View { @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 { @@ -33,11 +43,23 @@ struct WetterView: View { } .navigationTitle("Gassi-Wetter") .navigationBarTitleDisplayMode(.inline) - .task { location.request() } - .onChange(of: location.coordinate?.latitude) { _, _ in - Task { await loadWeather() } + .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) } - .refreshable { await loadWeather() } } private func content(weather: Weather) -> some View { @@ -46,6 +68,18 @@ struct WetterView: View { 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] @@ -421,8 +455,7 @@ struct WetterView: View { } } - private func loadWeather() async { - guard let coord = location.coordinate else { return } + private func loadWeather(coord: CLLocationCoordinate2D) async { isLoading = true errorMessage = nil defer { isLoading = false } @@ -430,9 +463,25 @@ struct WetterView: View { do { weather = try await WeatherService.shared.weather(for: loc) } catch { - errorMessage = error.localizedDescription + 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