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:
rene 2026-06-02 19:37:30 +02:00
parent 9e51f3910e
commit a2646a18ef
16 changed files with 769 additions and 199 deletions

View file

@ -259,7 +259,7 @@ struct DiaryEntry: Decodable, Identifiable {
var isMilestoneFlag: Bool { isMilestone == 1 } var isMilestoneFlag: Bool { isMilestone == 1 }
} }
struct DiaryMedia: Decodable, Identifiable { struct DiaryMedia: Codable, Identifiable {
let id: Int let id: Int
let url: String let url: String
let mediaType: String? let mediaType: String?

View file

@ -24,7 +24,11 @@ struct BanYaroGoApp: App {
} }
} }
} }
.modelContainer(for: [ActiveWalk.self, PhotoLocation.self]) .modelContainer(for: [
ActiveWalk.self, PhotoLocation.self,
CachedRoute.self, CachedDiaryEntry.self, CachedImage.self,
PendingRoute.self, PendingRoutePhoto.self
])
} }
private func handleIncoming(url: URL) { private func handleIncoming(url: URL) {

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

View file

@ -3,11 +3,26 @@ import Observation
import CoreLocation import CoreLocation
/// Asks CLLocationManager for the user's current location once. Used by /// 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 @Observable
@MainActor @MainActor
final class OneShotLocation: NSObject, CLLocationManagerDelegate { final class OneShotLocation: NSObject, CLLocationManagerDelegate {
private let manager = CLLocationManager() 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 coordinate: CLLocationCoordinate2D?
var error: String? var error: String?
@ -19,20 +34,61 @@ final class OneShotLocation: NSObject, CLLocationManagerDelegate {
manager.desiredAccuracy = kCLLocationAccuracyHundredMeters manager.desiredAccuracy = kCLLocationAccuracyHundredMeters
} }
/// Beobachtbarer Weg (Giftköder): startet die Auflösung, Ergebnis landet in
/// `coordinate`/`error`.
func request() { 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 error = nil
isResolving = true isResolving = true
switch manager.authorizationStatus { return await withCheckedContinuation { (cont: CheckedContinuation<CLLocationCoordinate2D?, Never>) in
case .notDetermined: self.continuation = cont
manager.requestWhenInUseAuthorization() self.startTimeout()
case .denied, .restricted: switch self.manager.authorizationStatus {
error = "Standortzugriff verweigert." case .notDetermined:
isResolving = false self.manager.requestWhenInUseAuthorization()
case .authorizedWhenInUse, .authorizedAlways: case .denied, .restricted:
manager.requestLocation() self.complete(coord: nil, errorText: "Standortzugriff verweigert.")
@unknown default: case .authorizedWhenInUse, .authorizedAlways:
error = "Unbekannter Standort-Status." self.manager.requestLocation()
isResolving = false @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, _ manager: CLLocationManager,
didUpdateLocations locations: [CLLocation] didUpdateLocations locations: [CLLocation]
) { ) {
guard let loc = locations.first else { return } guard let c = locations.first?.coordinate else { return }
let c = loc.coordinate Task { @MainActor in self.complete(coord: c, errorText: nil) }
Task { @MainActor in
self.coordinate = c
self.isResolving = false
}
} }
nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError err: Error) { nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError err: Error) {
let msg = err.localizedDescription let msg = err.localizedDescription
Task { @MainActor in Task { @MainActor in self.complete(coord: nil, errorText: msg) }
self.error = msg
self.isResolving = false
}
} }
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
let status = manager.authorizationStatus let status = manager.authorizationStatus
Task { @MainActor in Task { @MainActor in
guard self.continuation != nil else { return }
switch status { switch status {
case .authorizedWhenInUse, .authorizedAlways: case .authorizedWhenInUse, .authorizedAlways:
manager.requestLocation() manager.requestLocation()
case .denied, .restricted: case .denied, .restricted:
self.error = "Standortzugriff verweigert." self.complete(coord: nil, errorText: "Standortzugriff verweigert.")
self.isResolving = false
default: default:
break break
} }

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

View file

@ -0,0 +1,51 @@
import SwiftUI
import UIKit
import SwiftData
/// Wie `AsyncImage`, aber offline-fähig: zeigt zuerst ein lokal gecachtes Bild
/// (CachedImage in SwiftData); fehlt es, wird es remote geladen und dabei gleich
/// in den Offline-Cache geschrieben. `path` ist der relative Medienpfad
/// (z. B. "/media/routes/x.jpg"), die Basis-URL hängt OfflineCache an.
struct CachedAsyncImage<Content: View, Placeholder: View>: View {
private let path: String?
private let content: (Image) -> Content
private let placeholder: () -> Placeholder
@Environment(\.modelContext) private var ctx
@State private var uiImage: UIImage?
init(
path: String?,
@ViewBuilder content: @escaping (Image) -> Content,
@ViewBuilder placeholder: @escaping () -> Placeholder
) {
self.path = path
self.content = content
self.placeholder = placeholder
}
var body: some View {
Group {
if let uiImage {
content(Image(uiImage: uiImage))
} else {
placeholder()
}
}
.task(id: path) { await load() }
}
private func load() async {
uiImage = nil
guard let path, !path.isEmpty else { return }
if let data = OfflineCache.imageData(path: path, in: ctx), let img = UIImage(data: data) {
uiImage = img
return
}
guard let url = URL(string: OfflineCache.mediaBase + path) else { return }
if let (data, _) = try? await URLSession.shared.data(from: url), let img = UIImage(data: data) {
uiImage = img
OfflineCache.storeImage(path: path, data: data, in: ctx)
}
}
}

View file

@ -91,14 +91,10 @@ struct DiaryDetailView: View {
VStack(spacing: 8) { VStack(spacing: 8) {
TabView(selection: $photoIndex) { TabView(selection: $photoIndex) {
ForEach(Array(media.enumerated()), id: \.element.id) { idx, m in ForEach(Array(media.enumerated()), id: \.element.id) { idx, m in
AsyncImage(url: URL(string: "https://banyaro.app\(m.url)")) { phase in CachedAsyncImage(path: m.url) { img in
switch phase { img.resizable().scaledToFit()
case .success(let img): img.resizable().scaledToFit() } placeholder: {
case .failure: Image(systemName: "photo") ProgressView()
.font(.largeTitle)
.foregroundStyle(.secondary)
default: ProgressView()
}
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.tag(idx) .tag(idx)

View file

@ -1,96 +0,0 @@
import SwiftUI
struct DogsListView: View {
@State private var dogs: [Dog] = []
@State private var isLoading = false
@State private var errorMessage: String?
var body: some View {
NavigationStack {
content
.navigationTitle("Hunde")
.task { await load() }
}
}
@ViewBuilder
private var content: some View {
if isLoading && dogs.isEmpty {
ProgressView()
} else if let error = errorMessage, dogs.isEmpty {
ContentUnavailableView(
"Konnte Hunde nicht laden",
systemImage: "wifi.slash",
description: Text(error)
)
} else if dogs.isEmpty {
ContentUnavailableView(
"Keine Hunde",
systemImage: "pawprint",
description: Text("Lege deinen ersten Hund in der PWA an.")
)
} else {
List(dogs) { dog in
DogRow(dog: dog)
}
.refreshable { await load() }
}
}
private func load() async {
isLoading = true
errorMessage = nil
defer { isLoading = false }
do {
dogs = try await APIClient.shared.get("/api/dogs")
} catch {
errorMessage = error.localizedDescription
}
}
}
struct DogRow: View {
let dog: Dog
var body: some View {
HStack(spacing: 12) {
avatar
.frame(width: 56, height: 56)
.background(.background.secondary)
.clipShape(Circle())
VStack(alignment: .leading, spacing: 2) {
Text(dog.name).font(.headline)
if let rasse = dog.rasse, !rasse.isEmpty {
Text(rasse)
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
}
.padding(.vertical, 4)
}
@ViewBuilder
private var avatar: some View {
if let path = dog.fotoUrl, let url = URL(string: "https://banyaro.app\(path)") {
AsyncImage(url: url) { phase in
switch phase {
case .success(let img):
img.resizable().scaledToFill()
default:
placeholder
}
}
} else {
placeholder
}
}
private var placeholder: some View {
Image(systemName: "pawprint.fill")
.font(.title2)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}

View file

@ -25,6 +25,7 @@ struct FinishWalkSheet: View {
@State private var saveState: SaveState = .idle @State private var saveState: SaveState = .idle
@State private var errorMessage: String? @State private var errorMessage: String?
@State private var savedOffline = false
private enum SaveState: Equatable { private enum SaveState: Equatable {
case idle case idle
@ -144,7 +145,15 @@ struct FinishWalkSheet: View {
.onChange(of: photoSelection) { _, newItems in .onChange(of: photoSelection) { _, newItems in
Task { await loadPhotos(from: newItems) } Task { await loadPhotos(from: newItems) }
} }
.interactiveDismissDisabled(saveState != .idle) // Immer blockieren: eine aufgezeichnete Tour darf nicht durch
// versehentliches Runterwischen verloren gehen nur Speichern"
// oder Verwerfen" beenden das Sheet.
.interactiveDismissDisabled(true)
.alert("Offline gespeichert", isPresented: $savedOffline) {
Button("OK") { onSaved(); dismiss() }
} message: {
Text("Keine Internetverbindung. Die Tour ist lokal gesichert und wird automatisch hochgeladen, sobald du wieder online bist.")
}
} }
} }
@ -283,6 +292,15 @@ struct FinishWalkSheet: View {
do { do {
route = try await APIClient.shared.post("/api/routes", body: body) route = try await APIClient.shared.post("/api/routes", body: body)
} catch { } catch {
// Transportfehler (offline) Tour inkl. Fotos lokal in die Outbox,
// wird automatisch hochgeladen, sobald wieder Netz da ist.
if error is URLError {
OfflineCache.savePendingRoute(body: body, photos: photoData, in: modelContext)
await syncHealthIfEnabled()
saveState = .idle
savedOffline = true
return
}
errorMessage = error.localizedDescription errorMessage = error.localizedDescription
saveState = .idle saveState = .idle
return return
@ -321,19 +339,21 @@ struct FinishWalkSheet: View {
saveState = .uploadingPhotos(done: photoData.count, total: photoData.count) saveState = .uploadingPhotos(done: photoData.count, total: photoData.count)
} }
// Apple Health sync (only if user opted in) await syncHealthIfEnabled()
if healthKitSyncEnabled, points.count >= 2 {
let endedAt = Date.now
let startedAt = endedAt.addingTimeInterval(-Double(durationSeconds))
await WalkHealthSync.shared.saveWalk(
points: points,
startedAt: startedAt,
endedAt: endedAt,
distanceMeters: distanceMeters
)
}
onSaved() onSaved()
dismiss() dismiss()
} }
private func syncHealthIfEnabled() async {
guard healthKitSyncEnabled, points.count >= 2 else { return }
let endedAt = Date.now
let startedAt = endedAt.addingTimeInterval(-Double(durationSeconds))
await WalkHealthSync.shared.saveWalk(
points: points,
startedAt: startedAt,
endedAt: endedAt,
distanceMeters: distanceMeters
)
}
} }

View file

@ -1,7 +1,9 @@
import SwiftUI import SwiftUI
import SwiftData
struct MainTabView: View { struct MainTabView: View {
@Environment(AuthSession.self) private var auth @Environment(AuthSession.self) private var auth
@Environment(\.modelContext) private var ctx
var body: some View { var body: some View {
TabView { TabView {
@ -17,6 +19,10 @@ struct MainTabView: View {
SettingsView() SettingsView()
.tabItem { Label("Mehr", systemImage: "person.crop.circle") } .tabItem { Label("Mehr", systemImage: "person.crop.circle") }
} }
.task { await auth.loadProfile() } .task {
await auth.loadProfile()
// Offline gesicherte Touren beim Start hochladen (falls online).
await OfflineCache.syncPendingRoutes(in: ctx)
}
} }
} }

View file

@ -1,13 +1,25 @@
import SwiftUI import SwiftUI
import SwiftData
struct RootView: View { struct RootView: View {
@Environment(AuthSession.self) private var auth @Environment(AuthSession.self) private var auth
@Environment(\.modelContext) private var ctx
var body: some View { var body: some View {
if auth.isLoggedIn { Group {
MainTabView() if auth.isLoggedIn {
} else { MainTabView()
LoginView() } else {
LoginView()
}
}
// Offline-Cache leeren, sobald der User wechselt (Logout oder 401),
// damit nie Touren/Tagebuch/Fotos eines vorigen Users durchschimmern.
.onReceive(NotificationCenter.default.publisher(for: .userDidLogout)) { _ in
OfflineCache.clearAll(in: ctx)
}
.onReceive(NotificationCenter.default.publisher(for: .apiUnauthorized)) { _ in
OfflineCache.clearAll(in: ctx)
} }
} }
} }

View file

@ -8,6 +8,7 @@ struct RouteDetailView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Environment(AuthSession.self) private var auth @Environment(AuthSession.self) private var auth
@Environment(\.modelContext) private var ctx
@Query private var allPhotoLocations: [PhotoLocation] @Query private var allPhotoLocations: [PhotoLocation]
@State private var detail: RouteDetail? @State private var detail: RouteDetail?
@ -218,27 +219,32 @@ struct RouteDetailView: View {
} }
private func photoThumb(_ path: String) -> some View { private func photoThumb(_ path: String) -> some View {
let url = URL(string: "https://banyaro.app\(path)") CachedAsyncImage(path: path) { img in
return AsyncImage(url: url) { phase in img.resizable().scaledToFill()
switch phase { } placeholder: {
case .success(let img): Rectangle().fill(.gray.opacity(0.15))
img.resizable().scaledToFill()
default:
Rectangle().fill(.gray.opacity(0.15))
}
} }
.frame(width: 160, height: 160) .frame(width: 160, height: 160)
.clipShape(RoundedRectangle(cornerRadius: 10)) .clipShape(RoundedRectangle(cornerRadius: 10))
} }
private func load() async { private func load() async {
isLoading = true // 1) Cache-first: gespeicherten Detail-Stand zeigen (auch offline)
if detail == nil, let cached = OfflineCache.cachedRouteDetail(id: routeId, in: ctx) {
detail = cached
}
isLoading = (detail == nil)
errorMessage = nil errorMessage = nil
defer { isLoading = false } defer { isLoading = false }
// 2) Netzwerk Cache aktualisieren; bei Fehler bleibt der Cache stehen
do { do {
detail = try await APIClient.shared.get("/api/routes/\(routeId)") let fetched: RouteDetail = try await APIClient.shared.get("/api/routes/\(routeId)")
detail = fetched
OfflineCache.upsertRouteDetail(fetched, in: ctx)
let paths = fetched.fotoUrls ?? []
Task { await OfflineCache.prefetchImages(paths: paths, in: ctx) }
} catch { } catch {
errorMessage = error.localizedDescription if detail == nil { errorMessage = error.localizedDescription }
} }
} }
@ -307,17 +313,11 @@ private struct PhotoViewerSheet: View {
NavigationStack { NavigationStack {
ZStack { ZStack {
Color.black.ignoresSafeArea() Color.black.ignoresSafeArea()
if let url = URL(string: "https://banyaro.app\(path)") { if !path.isEmpty {
AsyncImage(url: url) { phase in CachedAsyncImage(path: path) { img in
switch phase { img.resizable().scaledToFit()
case .success(let img): } placeholder: {
img.resizable().scaledToFit() ProgressView().tint(.white)
case .failure:
ContentUnavailableView("Foto nicht ladbar", systemImage: "photo.badge.exclamationmark")
.foregroundStyle(.white)
default:
ProgressView().tint(.white)
}
} }
} }
} }

View file

@ -1,6 +1,9 @@
import SwiftUI import SwiftUI
import SwiftData
struct RoutesListView: View { struct RoutesListView: View {
@Environment(\.modelContext) private var ctx
@Query(sort: \PendingRoute.createdAt, order: .reverse) private var pending: [PendingRoute]
@State private var routes: [RouteListItem] = [] @State private var routes: [RouteListItem] = []
@State private var isLoading = false @State private var isLoading = false
@State private var errorMessage: String? @State private var errorMessage: String?
@ -15,26 +18,39 @@ struct RoutesListView: View {
@ViewBuilder @ViewBuilder
private var content: some View { private var content: some View {
if isLoading && routes.isEmpty { if isLoading && routes.isEmpty && pending.isEmpty {
ProgressView() ProgressView()
} else if let error = errorMessage, routes.isEmpty { } else if let error = errorMessage, routes.isEmpty && pending.isEmpty {
ContentUnavailableView( ContentUnavailableView(
"Konnte Touren nicht laden", "Konnte Touren nicht laden",
systemImage: "wifi.slash", systemImage: "wifi.slash",
description: Text(error) description: Text(error)
) )
} else if routes.isEmpty { } else if routes.isEmpty && pending.isEmpty {
ContentUnavailableView( ContentUnavailableView(
"Keine Touren", "Keine Touren",
systemImage: "map", systemImage: "map",
description: Text("Lege deine erste Gassi-Tour in der PWA an — oder warte auf Phase 2.") description: Text("Zeichne deine erste Gassi-Tour über den Tab „Aufnehmen“ auf — oder importiere einen GPX-Track.")
) )
} else { } else {
List(routes) { route in List {
NavigationLink { if !pending.isEmpty {
RouteDetailView(routeId: route.id, fallbackName: route.name) Section {
} label: { ForEach(pending) { p in
RouteRowView(route: route) PendingRouteRow(route: p)
}
} header: {
Label("Offline wird hochgeladen", systemImage: "icloud.and.arrow.up")
}
}
Section {
ForEach(routes) { route in
NavigationLink {
RouteDetailView(routeId: route.id, fallbackName: route.name)
} label: {
RouteRowView(route: route)
}
}
} }
} }
.refreshable { await load() } .refreshable { await load() }
@ -42,13 +58,25 @@ struct RoutesListView: View {
} }
private func load() async { private func load() async {
isLoading = true // 1) Cache-first: sofort anzeigen (auch offline)
if routes.isEmpty {
let cached = OfflineCache.cachedRoutes(in: ctx)
if !cached.isEmpty { routes = cached }
}
isLoading = routes.isEmpty
errorMessage = nil errorMessage = nil
defer { isLoading = false } defer { isLoading = false }
// 2) Offline gesicherte Touren hochladen (falls online)
await OfflineCache.syncPendingRoutes(in: ctx)
// 3) Netzwerk Cache aktualisieren; bei Fehler bleibt der Cache stehen
do { do {
routes = try await APIClient.shared.get("/api/routes") let fetched: [RouteListItem] = try await APIClient.shared.get("/api/routes")
routes = fetched
OfflineCache.upsertRoutes(fetched, in: ctx)
let paths = fetched.flatMap { $0.fotoUrls ?? [] }
Task { await OfflineCache.prefetchImages(paths: paths, in: ctx) }
} catch { } catch {
errorMessage = error.localizedDescription if routes.isEmpty { errorMessage = error.localizedDescription }
} }
} }
} }
@ -90,6 +118,42 @@ struct RouteRowView: View {
} }
} }
struct PendingRouteRow: View {
let route: PendingRoute
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(route.name)
.font(.headline)
Spacer()
Image(systemName: "icloud.and.arrow.up")
.foregroundStyle(.orange)
}
HStack(spacing: 12) {
Text(String(format: "%.1f km", route.distanzKm))
Text("\(route.dauerMin) min")
if !route.photos.isEmpty {
Label("\(route.photos.count)", systemImage: "photo")
}
Spacer()
Text("noch nicht hochgeladen")
.foregroundStyle(.orange)
}
.font(.caption)
.foregroundStyle(.secondary)
let track = route.gpsTrack
if track.count >= 2 {
MiniRouteMap(track: track)
.frame(height: 110)
.clipShape(RoundedRectangle(cornerRadius: 10))
}
}
.padding(.vertical, 4)
}
}
enum DateUtil { enum DateUtil {
/// Parses backend timestamps (SQLite `YYYY-MM-DD HH:MM:SS` or ISO-8601) /// Parses backend timestamps (SQLite `YYYY-MM-DD HH:MM:SS` or ISO-8601)
/// into a German short date. /// into a German short date.

View file

@ -101,7 +101,6 @@ struct SettingsView: View {
Section("Mehr auf banyaro.app") { Section("Mehr auf banyaro.app") {
pwaLink("Forum", systemImage: "bubble.left.and.bubble.right.fill", fragment: "forum") pwaLink("Forum", systemImage: "bubble.left.and.bubble.right.fill", fragment: "forum")
pwaLink("Hunde-Profile bearbeiten", systemImage: "pawprint.fill", fragment: "dogs") pwaLink("Hunde-Profile bearbeiten", systemImage: "pawprint.fill", fragment: "dogs")
pwaLink("Gassi-Treffen", systemImage: "person.2.fill", fragment: "walks")
pwaLink("Profil & Einstellungen", systemImage: "gearshape.fill", fragment: "settings") pwaLink("Profil & Einstellungen", systemImage: "gearshape.fill", fragment: "settings")
} }
@ -170,7 +169,7 @@ struct SettingsView: View {
} }
Section("Über") { Section("Über") {
Text("Ban Yaro Go ist die native iOS-Ergänzung zur banyaro.app PWA. Phase 1: deine Touren ansehen.") Text("Ban Yaro Go ist die native iOS-Ergänzung zur banyaro.app — fürs Gassi-Tracking, Wetter, Tagebuch und mehr unterwegs.")
.font(.footnote) .font(.footnote)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }

View file

@ -1,7 +1,9 @@
import SwiftUI import SwiftUI
import SwiftData
struct TagebuchView: View { struct TagebuchView: View {
@Environment(ActiveDogStore.self) private var activeDog @Environment(ActiveDogStore.self) private var activeDog
@Environment(\.modelContext) private var ctx
@State private var entries: [DiaryEntry] = [] @State private var entries: [DiaryEntry] = []
@State private var isLoading = false @State private var isLoading = false
@ -66,16 +68,26 @@ struct TagebuchView: View {
private func load() async { private func load() async {
guard let dog = activeDog.activeDog else { return } guard let dog = activeDog.activeDog else { return }
isLoading = true // 1) Cache-first: sofort anzeigen (auch offline)
if entries.isEmpty {
let cached = OfflineCache.cachedDiary(dogId: dog.id, in: ctx)
if !cached.isEmpty { entries = cached }
}
isLoading = entries.isEmpty
errorMessage = nil errorMessage = nil
defer { isLoading = false } defer { isLoading = false }
// 2) Netzwerk Cache aktualisieren; bei Fehler bleibt der Cache stehen
do { do {
entries = try await APIClient.shared.get("/api/dogs/\(dog.id)/diary?limit=50") let fetched: [DiaryEntry] = try await APIClient.shared.get("/api/dogs/\(dog.id)/diary?limit=50")
entries = fetched
OfflineCache.upsertDiary(fetched, in: ctx)
let paths = fetched.flatMap { ($0.mediaItems ?? []).map(\.url) }
Task { await OfflineCache.prefetchImages(paths: paths, in: ctx) }
} catch let decodingError as DecodingError { } catch let decodingError as DecodingError {
errorMessage = Self.describe(decodingError) if entries.isEmpty { errorMessage = Self.describe(decodingError) }
print("Tagebuch decode error: \(decodingError)") print("Tagebuch decode error: \(decodingError)")
} catch { } catch {
errorMessage = error.localizedDescription if entries.isEmpty { errorMessage = error.localizedDescription }
} }
} }
@ -131,11 +143,10 @@ private struct DiaryRow: View {
ScrollView(.horizontal, showsIndicators: false) { ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) { HStack(spacing: 6) {
ForEach(media) { m in ForEach(media) { m in
AsyncImage(url: URL(string: "https://banyaro.app\(m.url)")) { phase in CachedAsyncImage(path: m.url) { img in
switch phase { img.resizable().scaledToFill()
case .success(let img): img.resizable().scaledToFill() } placeholder: {
default: Rectangle().fill(.gray.opacity(0.15)) Rectangle().fill(.gray.opacity(0.15))
}
} }
.frame(width: 80, height: 80) .frame(width: 80, height: 80)
.clipShape(RoundedRectangle(cornerRadius: 8)) .clipShape(RoundedRectangle(cornerRadius: 8))

View file

@ -8,6 +8,16 @@ struct WetterView: View {
@State private var isLoading = false @State private var isLoading = false
@State private var errorMessage: String? @State private var errorMessage: String?
@State private var selectedDayIndex = 0 @State private var selectedDayIndex = 0
@State private var placeName: String?
@State private var isApproxLocation = false
/// Fallback-Standort (Berlin-Mitte): Wenn der Gerätestandort nicht
/// ermittelbar ist (Timeout/verweigert z. B. Apple-Review-iPad ohne
/// Position), lädt WeatherKit trotzdem eine Vorhersage, statt ewig bei
/// Hole Standort" zu hängen.
private static let fallbackCoordinate = CLLocationCoordinate2D(
latitude: 52.5200, longitude: 13.4050
)
var body: some View { var body: some View {
Group { Group {
@ -33,11 +43,23 @@ struct WetterView: View {
} }
.navigationTitle("Gassi-Wetter") .navigationTitle("Gassi-Wetter")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.task { location.request() } .task {
.onChange(of: location.coordinate?.latitude) { _, _ in // Deterministisch: erst Standort auflösen (Fix/Fehler/Timeout),
Task { await loadWeather() } // dann laden mit echtem Standort oder Berlin-Fallback. So hängt
// der Screen nie bei Hole Standort" und WeatherKit lädt immer.
let coord = await location.resolve()
isApproxLocation = (coord == nil)
let used = coord ?? Self.fallbackCoordinate
await loadWeather(coord: used)
placeName = isApproxLocation ? "Berlin" : await Self.reverseGeocode(used)
}
.refreshable {
let coord = location.coordinate
isApproxLocation = (coord == nil)
let used = coord ?? Self.fallbackCoordinate
await loadWeather(coord: used)
placeName = isApproxLocation ? "Berlin" : await Self.reverseGeocode(used)
} }
.refreshable { await loadWeather() }
} }
private func content(weather: Weather) -> some View { private func content(weather: Weather) -> some View {
@ -46,6 +68,18 @@ struct WetterView: View {
return ScrollView { return ScrollView {
VStack(spacing: 14) { VStack(spacing: 14) {
if let placeName {
HStack(spacing: 5) {
Image(systemName: isApproxLocation ? "location.slash" : "location.fill")
Text(placeName)
if isApproxLocation {
Text("· Näherung").foregroundStyle(.secondary)
}
}
.font(.subheadline)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
if !days.isEmpty { if !days.isEmpty {
dayPicker(days: days) dayPicker(days: days)
let day = days[safeIndex] let day = days[safeIndex]
@ -421,8 +455,7 @@ struct WetterView: View {
} }
} }
private func loadWeather() async { private func loadWeather(coord: CLLocationCoordinate2D) async {
guard let coord = location.coordinate else { return }
isLoading = true isLoading = true
errorMessage = nil errorMessage = nil
defer { isLoading = false } defer { isLoading = false }
@ -430,9 +463,25 @@ struct WetterView: View {
do { do {
weather = try await WeatherService.shared.weather(for: loc) weather = try await WeatherService.shared.weather(for: loc)
} catch { } catch {
errorMessage = error.localizedDescription errorMessage = Self.isOfflineError(error)
? "Wetter ist offline nicht verfügbar. Die Vorhersage lädt automatisch, sobald du wieder Internet hast."
: error.localizedDescription
} }
} }
/// Reverse-Geocoding Ortsname (Stadt). Braucht Netz; offline nil.
private static func reverseGeocode(_ coord: CLLocationCoordinate2D) async -> String? {
let loc = CLLocation(latitude: coord.latitude, longitude: coord.longitude)
let placemarks = try? await CLGeocoder().reverseGeocodeLocation(loc)
return placemarks?.first?.locality ?? placemarks?.first?.name
}
private static func isOfflineError(_ error: Error) -> Bool {
if error is URLError { return true }
let d = error.localizedDescription.lowercased()
return d.contains("internet") || d.contains("verbindung")
|| d.contains("network") || d.contains("offline")
}
} }
// MARK: - Day metrics derived from DayWeather // MARK: - Day metrics derived from DayWeather