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).
293 lines
10 KiB
Swift
293 lines
10 KiB
Swift
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<T: Encodable>(_ value: T) -> Data? { try? encoder.encode(value) }
|
|
static func decode<T: Decodable>(_ 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<CachedRoute>(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<CachedRoute>(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<CachedRoute>(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<CachedRoute>(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<CachedDiaryEntry>(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<CachedDiaryEntry>(
|
|
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<CachedImage>(predicate: #Predicate { $0.path == path })).first)?.data
|
|
}
|
|
|
|
static func storeImage(path: String, data: Data, in ctx: ModelContext) {
|
|
if let existing = try? ctx.fetch(FetchDescriptor<CachedImage>(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<CachedImage>(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()
|
|
}
|
|
}
|