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
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()
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue