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).
112 lines
3.9 KiB
Swift
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
|
|
}
|
|
}
|