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).
This commit is contained in:
parent
9e51f3910e
commit
a2646a18ef
16 changed files with 769 additions and 199 deletions
293
BanYaroGo/Support/OfflineCache.swift
Normal file
293
BanYaroGo/Support/OfflineCache.swift
Normal file
|
|
@ -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<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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Void, Never>?
|
||||
private let timeoutSeconds: Double = 10
|
||||
private var continuation: CheckedContinuation<CLLocationCoordinate2D?, Never>?
|
||||
|
||||
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<CLLocationCoordinate2D?, Never>) 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
|
||||
}
|
||||
|
|
|
|||
112
BanYaroGo/Support/Outbox.swift
Normal file
112
BanYaroGo/Support/Outbox.swift
Normal file
|
|
@ -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<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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue