Tagebuch + Heim-Tab mit täglichem Background
Tagebuch (Diary):
- DiaryEntry + DiaryMedia + DiaryCreateBody DTOs
- TagebuchView: Liste der Einträge für aktiven Hund mit Titel, Text,
Ortsname, Meilenstein-Stern, Foto-Strip
- AddDiaryEntrySheet: Titel/Text/Datum/Meilenstein/Ort/Tags +
PhotosPicker, nach POST /api/dogs/{id}/diary werden Fotos einzeln
via POST /api/dogs/{id}/diary/{entry_id}/media hochgeladen (mit
ImageResize.resizedJPEG)
Heim-Tab als neuer 1. Tab:
- DashboardSnapshot DTO für /api/dogs/{id}/welcome-dashboard
- ActiveDogStore (@Observable + UserDefaults("activeDogId")): hält
den aktiven Hund app-weit
- HeimView: tägliches Hintergrundfoto aus random_photo.url (rotiert
pro Tag, vom Backend gewählt), Gradient zur Lesbarkeit, Tagezeit-
Begrüßung mit User-Namen, Hund-Picker (Menu), Info-Karten für
letzten Eintrag/nächsten Termin/Gewicht/Eintragszahl,
Quick-Action-Buttons (Tagebuch, Wetter, Erste Hilfe)
Reorganisation:
- 5 Tabs: Heim, Touren, Aufnehmen, Statistik, Mehr
- Hunde-Liste wandert in Mehr → "Hund & Alltag"
- Tagebuch in Mehr → "Hund & Alltag" + erreichbar von Heim
This commit is contained in:
parent
68b084be97
commit
f054b2a07f
8 changed files with 712 additions and 3 deletions
140
BanYaroGo/Views/TagebuchView.swift
Normal file
140
BanYaroGo/Views/TagebuchView.swift
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
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 {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct DiaryRow: View {
|
||||
let entry: DiaryEntry
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
if entry.isMilestone == true || 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.media, !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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue