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