banyaro-ios/BanYaroGo/Views/HeimView.swift
rene f2e9d5deaf Heim: weißer Gradient raus, Tagesbild pro Tag cachen wie die PWA
- LinearGradient zum systemBackground entfernt — der Übergang war zu hart
  weiß und hat das Foto verschluckt
- UserDefaults-Cache 'heimPhoto_{userId}_{dogId}_YYYY-MM-DD' analog zur PWA
  (bg3_{userId}_YYYY-MM-DD). Sobald ein Foto pro Tag gewählt ist, sticht es
  bis Mitternacht — damit kippt's nicht mehr, wenn zwischendrin neue Bilder
  hochgeladen werden und die tick%len-Rotation auf einen anderen Index zeigt
2026-05-30 12:51:16 +02:00

263 lines
9.5 KiB
Swift

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 cachedPhotoUrl: String?
@State private var isLoading = false
private var photoCacheKey: String? {
guard let userId = auth.profile?.id,
let dogId = activeDog.activeDog?.id else { return nil }
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd"
return "heimPhoto_\(userId)_\(dogId)_\(f.string(from: .now))"
}
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 = cachedPhotoUrl ?? 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()
.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 appt = snap.nextAppointment, let bez = appt.bezeichnung {
infoCard(
icon: "calendar",
title: "Nächster Termin",
value: bez,
detail: appt.naechstes.map(DiaryUtil.format) ?? ""
)
}
}
}
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 }
// 1. Cached photo URL bevorzugen bleibt stabil über den Tag, identisch
// zur PWA (gleiches Cache-Schema: pro user+dog+datum).
// Auch nil setzen, falls der Cache-Key zum anderen Hund gehört.
cachedPhotoUrl = photoCacheKey.flatMap { UserDefaults.standard.string(forKey: $0) }
isLoading = true
defer { isLoading = false }
dashboard = try? await APIClient.shared.get("/api/dogs/\(dog.id)/welcome-dashboard")
// 2. Wenn noch nichts gecacht ist und das Backend ein Foto liefert,
// festhalten bis Mitternacht zeigen wir dasselbe Bild.
if cachedPhotoUrl == nil, let fresh = dashboard?.randomPhoto?.url, let key = photoCacheKey {
UserDefaults.standard.set(fresh, forKey: key)
cachedPhotoUrl = fresh
}
}
}