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