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