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
|
|
@ -185,6 +185,76 @@ struct LostDogCreateBody: Encodable {
|
||||||
let dogId: Int?
|
let dogId: Int?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Diary (Tagebuch)
|
||||||
|
|
||||||
|
struct DiaryEntry: Decodable, Identifiable {
|
||||||
|
let id: Int
|
||||||
|
let dogId: Int?
|
||||||
|
let datum: String?
|
||||||
|
let typ: String?
|
||||||
|
let titel: String?
|
||||||
|
let text: String?
|
||||||
|
let tags: [String]?
|
||||||
|
let gpsLat: Double?
|
||||||
|
let gpsLon: Double?
|
||||||
|
let locationName: String?
|
||||||
|
let isMilestone: Bool?
|
||||||
|
let media: [DiaryMedia]?
|
||||||
|
let createdAt: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DiaryMedia: Decodable, Identifiable {
|
||||||
|
let id: Int
|
||||||
|
let url: String
|
||||||
|
let mediaType: String?
|
||||||
|
let imgWidth: Int?
|
||||||
|
let imgHeight: Int?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DiaryCreateBody: Encodable {
|
||||||
|
let datum: String?
|
||||||
|
let typ: String
|
||||||
|
let titel: String?
|
||||||
|
let text: String?
|
||||||
|
let tags: [String]?
|
||||||
|
let gpsLat: Double?
|
||||||
|
let gpsLon: Double?
|
||||||
|
let locationName: String?
|
||||||
|
let isMilestone: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Welcome Dashboard
|
||||||
|
|
||||||
|
struct DashboardSnapshot: Decodable {
|
||||||
|
let randomPhoto: DashboardPhoto?
|
||||||
|
let lastDiary: DashboardLastDiary?
|
||||||
|
let nextAppointment: DashboardNextAppointment?
|
||||||
|
let lastWeight: DashboardLastWeight?
|
||||||
|
let diaryCount: Int?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DashboardPhoto: Decodable {
|
||||||
|
let url: String
|
||||||
|
let previewUrl: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DashboardLastDiary: Decodable {
|
||||||
|
let titel: String?
|
||||||
|
let datum: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DashboardNextAppointment: Decodable {
|
||||||
|
let bezeichnung: String?
|
||||||
|
let naechstes: String?
|
||||||
|
let typ: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DashboardLastWeight: Decodable {
|
||||||
|
let wert: Double?
|
||||||
|
let einheit: String?
|
||||||
|
let datum: String?
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Weather
|
// MARK: - Weather
|
||||||
|
|
||||||
struct WeatherForecast: Decodable {
|
struct WeatherForecast: Decodable {
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,13 @@ import SwiftData
|
||||||
@main
|
@main
|
||||||
struct BanYaroGoApp: App {
|
struct BanYaroGoApp: App {
|
||||||
@State private var auth = AuthSession()
|
@State private var auth = AuthSession()
|
||||||
|
@State private var activeDog = ActiveDogStore()
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
RootView()
|
RootView()
|
||||||
.environment(auth)
|
.environment(auth)
|
||||||
|
.environment(activeDog)
|
||||||
}
|
}
|
||||||
.modelContainer(for: [ActiveWalk.self, PhotoLocation.self])
|
.modelContainer(for: [ActiveWalk.self, PhotoLocation.self])
|
||||||
}
|
}
|
||||||
|
|
|
||||||
37
BanYaroGo/Support/ActiveDogStore.swift
Normal file
37
BanYaroGo/Support/ActiveDogStore.swift
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
|
||||||
|
/// Tracks the user's currently selected dog across the app. Picked once,
|
||||||
|
/// reused everywhere (Heim, Tagebuch, Statistik, …). Persisted in UserDefaults.
|
||||||
|
@Observable
|
||||||
|
@MainActor
|
||||||
|
final class ActiveDogStore {
|
||||||
|
var activeDogId: Int
|
||||||
|
var dogs: [Dog] = []
|
||||||
|
|
||||||
|
var activeDog: Dog? {
|
||||||
|
dogs.first(where: { $0.id == activeDogId }) ?? dogs.first
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
self.activeDogId = UserDefaults.standard.integer(forKey: "activeDogId")
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadDogs() async {
|
||||||
|
do {
|
||||||
|
let fetched: [Dog] = try await APIClient.shared.get("/api/dogs")
|
||||||
|
self.dogs = fetched
|
||||||
|
if !fetched.contains(where: { $0.id == activeDogId }),
|
||||||
|
let first = fetched.first {
|
||||||
|
setActive(first.id)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
print("ActiveDogStore loadDogs failed: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setActive(_ dogId: Int) {
|
||||||
|
activeDogId = dogId
|
||||||
|
UserDefaults.standard.set(dogId, forKey: "activeDogId")
|
||||||
|
}
|
||||||
|
}
|
||||||
178
BanYaroGo/Views/AddDiaryEntrySheet.swift
Normal file
178
BanYaroGo/Views/AddDiaryEntrySheet.swift
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
import SwiftUI
|
||||||
|
import PhotosUI
|
||||||
|
|
||||||
|
struct AddDiaryEntrySheet: View {
|
||||||
|
let dogId: Int
|
||||||
|
let onSaved: () async -> Void
|
||||||
|
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
@State private var titel = ""
|
||||||
|
@State private var text = ""
|
||||||
|
@State private var date = Date()
|
||||||
|
@State private var isMilestone = false
|
||||||
|
@State private var locationName = ""
|
||||||
|
@State private var tagsInput = ""
|
||||||
|
|
||||||
|
@State private var photoSelection: [PhotosPickerItem] = []
|
||||||
|
@State private var photoData: [Data] = []
|
||||||
|
|
||||||
|
@State private var saveState: SaveState = .idle
|
||||||
|
@State private var errorMessage: String?
|
||||||
|
|
||||||
|
private enum SaveState: Equatable {
|
||||||
|
case idle
|
||||||
|
case savingEntry
|
||||||
|
case uploadingMedia(done: Int, total: Int)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section("Titel") {
|
||||||
|
TextField("z. B. Erster Strandbesuch", text: $titel)
|
||||||
|
}
|
||||||
|
Section("Text") {
|
||||||
|
TextField("Was hat dein Hund heute erlebt?", text: $text, axis: .vertical)
|
||||||
|
.lineLimit(4...10)
|
||||||
|
}
|
||||||
|
Section("Datum") {
|
||||||
|
DatePicker("Datum", selection: $date, displayedComponents: .date)
|
||||||
|
.environment(\.locale, Locale(identifier: "de_DE"))
|
||||||
|
}
|
||||||
|
Section {
|
||||||
|
Toggle("Meilenstein", isOn: $isMilestone)
|
||||||
|
TextField("Ort (optional)", text: $locationName)
|
||||||
|
TextField("Tags (komma-getrennt)", text: $tagsInput)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
PhotosPicker(
|
||||||
|
selection: $photoSelection,
|
||||||
|
maxSelectionCount: 6,
|
||||||
|
matching: .images
|
||||||
|
) {
|
||||||
|
Label(photoData.isEmpty ? "Fotos hinzufügen" : "Fotos ändern", systemImage: "photo.badge.plus")
|
||||||
|
}
|
||||||
|
if !photoData.isEmpty {
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ForEach(Array(photoData.enumerated()), id: \.offset) { _, d in
|
||||||
|
if let img = UIImage(data: d) {
|
||||||
|
Image(uiImage: img)
|
||||||
|
.resizable()
|
||||||
|
.scaledToFill()
|
||||||
|
.frame(width: 80, height: 80)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text(photoData.isEmpty ? "Fotos" : "Fotos (\(photoData.count))")
|
||||||
|
}
|
||||||
|
|
||||||
|
if let errorMessage {
|
||||||
|
Section { Text(errorMessage).font(.footnote).foregroundStyle(.red) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Neuer Eintrag")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Abbrechen") { dismiss() }
|
||||||
|
.disabled(saveState != .idle)
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
saveToolbarItem
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: photoSelection) { _, items in
|
||||||
|
Task { await loadPhotos(from: items) }
|
||||||
|
}
|
||||||
|
.interactiveDismissDisabled(saveState != .idle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var saveToolbarItem: some View {
|
||||||
|
switch saveState {
|
||||||
|
case .idle:
|
||||||
|
Button("Sichern") { Task { await save() } }
|
||||||
|
.disabled(titel.trimmingCharacters(in: .whitespaces).isEmpty &&
|
||||||
|
text.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||||
|
case .savingEntry:
|
||||||
|
ProgressView()
|
||||||
|
case .uploadingMedia(let done, let total):
|
||||||
|
Text("\(done)/\(total)").font(.caption.monospacedDigit())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadPhotos(from items: [PhotosPickerItem]) async {
|
||||||
|
var loaded: [Data] = []
|
||||||
|
for item in items {
|
||||||
|
if let d = try? await item.loadTransferable(type: Data.self) {
|
||||||
|
loaded.append(d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
photoData = loaded
|
||||||
|
}
|
||||||
|
|
||||||
|
private func save() async {
|
||||||
|
errorMessage = nil
|
||||||
|
saveState = .savingEntry
|
||||||
|
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "yyyy-MM-dd"
|
||||||
|
let tags = tagsInput
|
||||||
|
.split(separator: ",")
|
||||||
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
|
||||||
|
let body = DiaryCreateBody(
|
||||||
|
datum: formatter.string(from: date),
|
||||||
|
typ: isMilestone ? "meilenstein" : "eintrag",
|
||||||
|
titel: titel.trimmingCharacters(in: .whitespaces).isEmpty ? nil : titel.trimmingCharacters(in: .whitespaces),
|
||||||
|
text: text.trimmingCharacters(in: .whitespaces).isEmpty ? nil : text.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
tags: tags.isEmpty ? nil : tags,
|
||||||
|
gpsLat: nil,
|
||||||
|
gpsLon: nil,
|
||||||
|
locationName: locationName.trimmingCharacters(in: .whitespaces).isEmpty ? nil : locationName,
|
||||||
|
isMilestone: isMilestone
|
||||||
|
)
|
||||||
|
|
||||||
|
let entry: DiaryEntry
|
||||||
|
do {
|
||||||
|
entry = try await APIClient.shared.post("/api/dogs/\(dogId)/diary", body: body)
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
saveState = .idle
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !photoData.isEmpty {
|
||||||
|
for (i, raw) in photoData.enumerated() {
|
||||||
|
saveState = .uploadingMedia(done: i, total: photoData.count)
|
||||||
|
let resized = ImageResize.resizedJPEG(from: raw)
|
||||||
|
do {
|
||||||
|
_ = try await APIClient.shared.uploadFile(
|
||||||
|
"/api/dogs/\(dogId)/diary/\(entry.id)/media",
|
||||||
|
filename: "media_\(i + 1).jpg",
|
||||||
|
data: resized
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
errorMessage = "Eintrag gespeichert, Foto \(i + 1) fehlgeschlagen: \(error.localizedDescription)"
|
||||||
|
await onSaved()
|
||||||
|
saveState = .idle
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await onSaved()
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,15 +5,15 @@ struct MainTabView: View {
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
TabView {
|
TabView {
|
||||||
|
HeimView()
|
||||||
|
.tabItem { Label("Heim", systemImage: "house.fill") }
|
||||||
|
|
||||||
RoutesListView()
|
RoutesListView()
|
||||||
.tabItem { Label("Touren", systemImage: "map.fill") }
|
.tabItem { Label("Touren", systemImage: "map.fill") }
|
||||||
|
|
||||||
TrackingView()
|
TrackingView()
|
||||||
.tabItem { Label("Aufnehmen", systemImage: "figure.walk") }
|
.tabItem { Label("Aufnehmen", systemImage: "figure.walk") }
|
||||||
|
|
||||||
DogsListView()
|
|
||||||
.tabItem { Label("Hunde", systemImage: "pawprint.fill") }
|
|
||||||
|
|
||||||
StatisticsView()
|
StatisticsView()
|
||||||
.tabItem { Label("Statistik", systemImage: "chart.bar.fill") }
|
.tabItem { Label("Statistik", systemImage: "chart.bar.fill") }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,16 @@ struct SettingsView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
Section("Hund & Alltag") {
|
Section("Hund & Alltag") {
|
||||||
|
NavigationLink {
|
||||||
|
DogsListView()
|
||||||
|
} label: {
|
||||||
|
Label("Meine Hunde", systemImage: "pawprint.fill")
|
||||||
|
}
|
||||||
|
NavigationLink {
|
||||||
|
TagebuchView()
|
||||||
|
} label: {
|
||||||
|
Label("Tagebuch", systemImage: "book.fill")
|
||||||
|
}
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
ErsteHilfeView()
|
ErsteHilfeView()
|
||||||
} label: {
|
} label: {
|
||||||
|
|
|
||||||
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