banyaro-ios/BanYaroGo/Support/Outbox.swift
rene a2646a18ef 1.1: Offline-Cache + Outbox für Touren/Tagebuch, WeatherKit-Fix, Aufräumen
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).
2026-06-02 19:37:30 +02:00

112 lines
3.9 KiB
Swift

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<PendingRoute>())) ?? 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<PendingRoute>(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
}
}