- 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
158 lines
5.9 KiB
Swift
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
|
|
}
|
|
}
|