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).
175 lines
6.7 KiB
Swift
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
|
|
}
|
|
}
|