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
272
BanYaroGo/Views/HeimView.swift
Normal file
272
BanYaroGo/Views/HeimView.swift
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
import SwiftUI
|
||||
|
||||
struct HeimView: View {
|
||||
@Environment(AuthSession.self) private var auth
|
||||
@Environment(ActiveDogStore.self) private var activeDog
|
||||
|
||||
@State private var dashboard: DashboardSnapshot?
|
||||
@State private var isLoading = false
|
||||
|
||||
private var greeting: String {
|
||||
let hour = Calendar.current.component(.hour, from: .now)
|
||||
let name = auth.profile?.name ?? auth.userName ?? ""
|
||||
let salute: String = {
|
||||
switch hour {
|
||||
case 5..<11: return "Guten Morgen"
|
||||
case 11..<14: return "Hallo"
|
||||
case 14..<18: return "Hallo"
|
||||
case 18..<22: return "Guten Abend"
|
||||
default: return "Gute Nacht"
|
||||
}
|
||||
}()
|
||||
return name.isEmpty ? salute : "\(salute), \(name)"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
background
|
||||
.ignoresSafeArea()
|
||||
|
||||
ScrollView {
|
||||
VStack(spacing: 16) {
|
||||
Spacer(minLength: 200) // breathing room for the photo
|
||||
header
|
||||
dogPickerCard
|
||||
if let dashboard {
|
||||
dashboardCards(dashboard)
|
||||
}
|
||||
quickActions
|
||||
Spacer(minLength: 24)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
.refreshable { await load() }
|
||||
}
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar(.hidden, for: .navigationBar)
|
||||
.task {
|
||||
if activeDog.dogs.isEmpty { await activeDog.loadDogs() }
|
||||
await load()
|
||||
}
|
||||
.onChange(of: activeDog.activeDogId) { _, _ in
|
||||
Task { await load() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var background: some View {
|
||||
ZStack {
|
||||
Color.accentColor.opacity(0.08).ignoresSafeArea()
|
||||
if let path = dashboard?.randomPhoto?.url,
|
||||
let url = URL(string: "https://banyaro.app\(path)") {
|
||||
AsyncImage(url: url) { phase in
|
||||
switch phase {
|
||||
case .success(let img):
|
||||
img.resizable().scaledToFill()
|
||||
default:
|
||||
Color.clear
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: 320, alignment: .top)
|
||||
.clipped()
|
||||
.overlay(
|
||||
LinearGradient(
|
||||
colors: [.clear, .clear, Color(.systemBackground).opacity(0.95), Color(.systemBackground)],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
VStack(spacing: 4) {
|
||||
Text(greeting)
|
||||
.font(.title2.bold())
|
||||
.multilineTextAlignment(.center)
|
||||
.shadow(color: .black.opacity(0.4), radius: 3, y: 1)
|
||||
if let dog = activeDog.activeDog {
|
||||
Text("Was machst du heute mit \(dog.name)?")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.bottom, 6)
|
||||
}
|
||||
|
||||
private var dogPickerCard: some View {
|
||||
Group {
|
||||
if activeDog.dogs.isEmpty {
|
||||
EmptyView()
|
||||
} else {
|
||||
Menu {
|
||||
ForEach(activeDog.dogs) { dog in
|
||||
Button {
|
||||
activeDog.setActive(dog.id)
|
||||
} label: {
|
||||
if dog.id == activeDog.activeDogId {
|
||||
Label(dog.name, systemImage: "checkmark")
|
||||
} else {
|
||||
Text(dog.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
dogAvatar
|
||||
.frame(width: 48, height: 48)
|
||||
.clipShape(Circle())
|
||||
VStack(alignment: .leading) {
|
||||
Text(activeDog.activeDog?.name ?? "Hund wählen")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.primary)
|
||||
if let rasse = activeDog.activeDog?.rasse, !rasse.isEmpty {
|
||||
Text(rasse)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "chevron.up.chevron.down")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(14)
|
||||
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 16))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var dogAvatar: some View {
|
||||
if let path = activeDog.activeDog?.fotoUrl,
|
||||
let url = URL(string: "https://banyaro.app\(path)") {
|
||||
AsyncImage(url: url) { phase in
|
||||
switch phase {
|
||||
case .success(let img): img.resizable().scaledToFill()
|
||||
default: avatarFallback
|
||||
}
|
||||
}
|
||||
} else {
|
||||
avatarFallback
|
||||
}
|
||||
}
|
||||
|
||||
private var avatarFallback: some View {
|
||||
ZStack {
|
||||
Color.accentColor.opacity(0.2)
|
||||
Image(systemName: "pawprint.fill")
|
||||
.foregroundStyle(Color.accentColor)
|
||||
}
|
||||
}
|
||||
|
||||
private func dashboardCards(_ snap: DashboardSnapshot) -> some View {
|
||||
VStack(spacing: 10) {
|
||||
if let last = snap.lastDiary, last.titel != nil {
|
||||
infoCard(
|
||||
icon: "book.fill",
|
||||
title: "Letzter Eintrag",
|
||||
value: last.titel ?? "—",
|
||||
detail: last.datum.map(DiaryUtil.format) ?? ""
|
||||
)
|
||||
}
|
||||
if let appt = snap.nextAppointment, let bez = appt.bezeichnung {
|
||||
infoCard(
|
||||
icon: "calendar",
|
||||
title: "Nächster Termin",
|
||||
value: bez,
|
||||
detail: appt.naechstes.map(DiaryUtil.format) ?? ""
|
||||
)
|
||||
}
|
||||
if let weight = snap.lastWeight, let wert = weight.wert {
|
||||
infoCard(
|
||||
icon: "scalemass.fill",
|
||||
title: "Letztes Gewicht",
|
||||
value: String(format: "%.1f %@", wert, weight.einheit ?? "kg"),
|
||||
detail: weight.datum.map(DiaryUtil.format) ?? ""
|
||||
)
|
||||
}
|
||||
if let count = snap.diaryCount, count > 0 {
|
||||
infoCard(
|
||||
icon: "books.vertical.fill",
|
||||
title: "Tagebucheinträge",
|
||||
value: "\(count)",
|
||||
detail: ""
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func infoCard(icon: String, title: String, value: String, detail: String) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: icon)
|
||||
.font(.title3)
|
||||
.foregroundStyle(Color.accentColor)
|
||||
.frame(width: 28)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title).font(.caption).foregroundStyle(.secondary)
|
||||
Text(value).font(.subheadline.bold())
|
||||
if !detail.isEmpty {
|
||||
Text(detail).font(.caption2).foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(14)
|
||||
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
|
||||
private var quickActions: some View {
|
||||
VStack(spacing: 10) {
|
||||
NavigationLink {
|
||||
TagebuchView()
|
||||
} label: {
|
||||
actionRow(icon: "book.fill", title: "Tagebuch", subtitle: "Eintrag anlegen oder lesen")
|
||||
}
|
||||
NavigationLink {
|
||||
WetterView()
|
||||
} label: {
|
||||
actionRow(icon: "cloud.sun.fill", title: "Wetter", subtitle: "Vorhersage für heute")
|
||||
}
|
||||
NavigationLink {
|
||||
ErsteHilfeView()
|
||||
} label: {
|
||||
actionRow(icon: "cross.case.fill", title: "Erste Hilfe", subtitle: "Notfall-Anleitung — offline")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func actionRow(icon: String, title: String, subtitle: String) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: icon)
|
||||
.font(.title3)
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 36, height: 36)
|
||||
.background(Color.accentColor, in: RoundedRectangle(cornerRadius: 10))
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title).font(.subheadline.bold()).foregroundStyle(.primary)
|
||||
Text(subtitle).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(14)
|
||||
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
|
||||
private func load() async {
|
||||
guard let dog = activeDog.activeDog else { return }
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
dashboard = try? await APIClient.shared.get("/api/dogs/\(dog.id)/welcome-dashboard")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue