banyaro-ios/BanYaroGo/Views/AddDiaryEntrySheet.swift
rene f054b2a07f 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
2026-05-30 12:22:51 +02:00

178 lines
6.6 KiB
Swift

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()
}
}