banyaro-ios/BanYaroGo/Views/TagebuchView.swift
rene 89d1d47ca4 Tagebuch: Detail-Ansicht + tappbare Listen-Zeilen
DiaryDetailView mit Header (Datum, Meilenstein-Badge, Ort), Foto-
Galerie als TabView mit Page-Indicator, Volltext, Tags als Chips,
Mini-Karte für GPS-Eintrag mit Tap nach Apple Maps, und 'Eintrag
löschen' mit Bestätigungs-Alert → DELETE /api/dogs/{id}/diary/{eid}.

Liste in TagebuchView jetzt mit NavigationLink statt nur Anzeige.

App-Store-Material:
- AppStore/marketing.md mit Name, Untertitel, Beschreibung 2700
  Zeichen, Keywords, Privacy-Antworten, Reviewer-Notiz
- AppStore/screenshots/ — 8 Stück 1320x2868 (iPhone 17 Pro Max 6.9'')
2026-05-30 14:53:44 +02:00

164 lines
6.2 KiB
Swift

import SwiftUI
struct TagebuchView: View {
@Environment(ActiveDogStore.self) private var activeDog
@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 }
isLoading = true
errorMessage = nil
defer { isLoading = false }
do {
entries = try await APIClient.shared.get("/api/dogs/\(dog.id)/diary?limit=50")
} catch let decodingError as DecodingError {
errorMessage = Self.describe(decodingError)
print("Tagebuch decode error: \(decodingError)")
} catch {
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
AsyncImage(url: URL(string: "https://banyaro.app\(m.url)")) { phase in
switch phase {
case .success(let img): img.resizable().scaledToFill()
default: 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
}
}