banyaro-ios/BanYaroGo/Views/TagebuchView.swift
rene a2646a18ef 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).
2026-06-02 19:37:30 +02:00

175 lines
6.7 KiB
Swift

import SwiftUI
import SwiftData
struct TagebuchView: View {
@Environment(ActiveDogStore.self) private var activeDog
@Environment(\.modelContext) private var ctx
@State private var entries: [DiaryEntry] = []
@State private var isLoading = false
@State private var errorMessage: String?
@State private var showAdd = false
var body: some View {
content
.navigationTitle("Tagebuch")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button { showAdd = true } label: {
Image(systemName: "plus")
}
.disabled(activeDog.activeDog == nil)
}
}
.sheet(isPresented: $showAdd) {
if let dog = activeDog.activeDog {
AddDiaryEntrySheet(dogId: dog.id) { await load() }
}
}
.task {
if activeDog.dogs.isEmpty { await activeDog.loadDogs() }
await load()
}
.refreshable { await load() }
}
@ViewBuilder
private var content: some View {
if activeDog.activeDog == nil {
ContentUnavailableView(
"Noch kein Hund",
systemImage: "pawprint",
description: Text("Lege deinen ersten Hund in der PWA an, dann kannst du Tagebucheinträge anlegen.")
)
} else if isLoading && entries.isEmpty {
ProgressView()
} else if let errorMessage, entries.isEmpty {
ContentUnavailableView("Konnte nicht laden", systemImage: "wifi.slash", description: Text(errorMessage))
} else if entries.isEmpty {
ContentUnavailableView(
"Noch keine Einträge",
systemImage: "book",
description: Text("Tippe oben rechts auf +, um deinen ersten Eintrag anzulegen.")
)
} else if let dog = activeDog.activeDog {
List(entries) { entry in
NavigationLink {
DiaryDetailView(dogId: dog.id, entry: entry) {
await load()
}
} label: {
DiaryRow(entry: entry)
}
}
.listStyle(.plain)
}
}
private func load() async {
guard let dog = activeDog.activeDog else { return }
// 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
defer { isLoading = false }
// 2) Netzwerk Cache aktualisieren; bei Fehler bleibt der Cache stehen
do {
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 {
if entries.isEmpty { errorMessage = Self.describe(decodingError) }
print("Tagebuch decode error: \(decodingError)")
} catch {
if entries.isEmpty { errorMessage = error.localizedDescription }
}
}
private static func describe(_ error: DecodingError) -> String {
switch error {
case .typeMismatch(let type, let ctx):
return "Feldtyp falsch (\(type)) bei „\(ctx.codingPath.map(\.stringValue).joined(separator: "."))"
case .valueNotFound(let type, let ctx):
return "Feld fehlt (\(type)) bei „\(ctx.codingPath.map(\.stringValue).joined(separator: "."))"
case .keyNotFound(let key, let ctx):
return "Key fehlt: \(key.stringValue) bei „\(ctx.codingPath.map(\.stringValue).joined(separator: "."))"
case .dataCorrupted(let ctx):
return "Datenfehler bei „\(ctx.codingPath.map(\.stringValue).joined(separator: ".")): \(ctx.debugDescription)"
@unknown default:
return String(describing: error)
}
}
}
private struct DiaryRow: View {
let entry: DiaryEntry
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .firstTextBaseline) {
if entry.isMilestoneFlag || entry.typ == "meilenstein" {
Image(systemName: "star.fill").foregroundStyle(.orange)
}
if let titel = entry.titel, !titel.isEmpty {
Text(titel).font(.headline)
} else {
Text("Eintrag").font(.headline).foregroundStyle(.secondary)
}
Spacer()
if let datum = entry.datum {
Text(DiaryUtil.format(datum))
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
}
}
if let text = entry.text, !text.isEmpty {
Text(text)
.font(.subheadline)
.lineLimit(3)
.foregroundStyle(.secondary)
}
if let loc = entry.locationName, !loc.isEmpty {
Label(loc, systemImage: "mappin.and.ellipse")
.font(.caption)
.foregroundStyle(.tertiary)
}
if let media = entry.mediaItems, !media.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) {
ForEach(media) { m in
CachedAsyncImage(path: m.url) { img in
img.resizable().scaledToFill()
} placeholder: {
Rectangle().fill(.gray.opacity(0.15))
}
.frame(width: 80, height: 80)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
}
}
}
.padding(.vertical, 6)
}
}
enum DiaryUtil {
static func format(_ str: String) -> String {
let parser = DateFormatter()
parser.locale = Locale(identifier: "en_US_POSIX")
parser.dateFormat = "yyyy-MM-dd"
if let d = parser.date(from: String(str.prefix(10))) {
let out = DateFormatter()
out.locale = Locale(identifier: "de_DE")
out.dateStyle = .medium
return out.string(from: d)
}
return str
}
}