banyaro-ios/BanYaroGo/Views/TagebuchView.swift
rene 0867a2171f Statistik weg, Mehr entrümpelt, Gassi-Zeiten korrekt gerahmt
- Statistik-Tab raus (für Go-Companion nicht relevant)
- Mehr-Duplikate raus: Meine Hunde, Tagebuch, Wetter, Erste Hilfe sitzen
  bereits auf Heim als Quick-Action bzw. im Dog-Picker
- Im PWA ist 'Gassi' der social walks-Bereich (walks.py) und 'Stamm-Gassi-
  Zeiten' nur ein Tab darin (Community-Pool, gassi_zeiten.py). Meine
  Implementierung als 'tägliche Erinnerungen' war fachlich falsch:
  + Mehr-Eintrag heißt jetzt 'Stamm-Gassi-Zeiten'
  + ContentUnavailableView + Footer erklären die Community-Komponente
  + Pitch-Karte unterscheidet jetzt klar: 'Gassi-Treffen' (sich verabreden)
    und 'Stamm-Gassi-Zeiten' (regelmäßige Runden + Pool)
  + 'Hunde-Orte' getrennt als eigener Pitch-Punkt
2026-05-30 13:04:35 +02:00

158 lines
5.9 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 {
List(entries) { entry in
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
}
}