import SwiftUI import WidgetKit import UIKit 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: 250) // 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 ?? cachedPhotoUrl, 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 } // Cache als Fallback, falls Backend offline. Nicht als Vorzug — // sonst klebt eine tote URL (gelöschtes Bild) über den ganzen Tag. 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") // Backend ist Source-of-Truth: wenn es ein Foto liefert, ist das // aktuell und im Server-Cache stabil. Lokal denselben Wert ablegen. if let fresh = dashboard?.randomPhoto?.url, let key = photoCacheKey { if fresh != cachedPhotoUrl { UserDefaults.standard.set(fresh, forKey: key) } cachedPhotoUrl = fresh } await updateWidgetSnapshot(dog: dog) } /// Schreibt einen Snapshot (kleines Foto + nächster Termin) in die App Group /// fürs Home-Screen-Widget und triggert ein Widget-Reload. private func updateWidgetSnapshot(dog: Dog) async { var photoJPEG: Data? if let path = dashboard?.randomPhoto?.previewUrl ?? dashboard?.randomPhoto?.url, let url = URL(string: "https://banyaro.app\(path)"), let (data, _) = try? await URLSession.shared.data(from: url) { photoJPEG = Self.widgetThumbnail(from: data) } let appt: String? = { guard let a = dashboard?.nextAppointment, let bez = a.bezeichnung, !bez.isEmpty else { return nil } if let date = a.naechstes { return "\(bez) · \(DiaryUtil.format(date))" } return bez }() HomeWidgetStore.save(HomeWidgetData( dogName: dog.name, photoJPEG: photoJPEG, nextAppointment: appt, diaryCount: dashboard?.diaryCount, updatedAt: Date() )) WidgetCenter.shared.reloadAllTimelines() } private static func widgetThumbnail(from data: Data, max: CGFloat = 600) -> Data? { guard let img = UIImage(data: data) else { return nil } let longest = Swift.max(img.size.width, img.size.height) let scale = longest > max ? max / longest : 1 let size = CGSize(width: img.size.width * scale, height: img.size.height * scale) let rendered = UIGraphicsImageRenderer(size: size).image { _ in img.draw(in: CGRect(origin: .zero, size: size)) } return rendered.jpegData(compressionQuality: 0.7) } }