Tagebuch: Detail-Ansicht + tappbare Listen-Zeilen
DiaryDetailView mit Header (Datum, Meilenstein-Badge, Ort), Foto-
Galerie als TabView mit Page-Indicator, Volltext, Tags als Chips,
Mini-Karte für GPS-Eintrag mit Tap nach Apple Maps, und 'Eintrag
löschen' mit Bestätigungs-Alert → DELETE /api/dogs/{id}/diary/{eid}.
Liste in TagebuchView jetzt mit NavigationLink statt nur Anzeige.
App-Store-Material:
- AppStore/marketing.md mit Name, Untertitel, Beschreibung 2700
Zeichen, Keywords, Privacy-Antworten, Reviewer-Notiz
- AppStore/screenshots/ — 8 Stück 1320x2868 (iPhone 17 Pro Max 6.9'')
This commit is contained in:
parent
7848817cbe
commit
89d1d47ca4
11 changed files with 491 additions and 2 deletions
203
BanYaroGo/Views/DiaryDetailView.swift
Normal file
203
BanYaroGo/Views/DiaryDetailView.swift
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
import SwiftUI
|
||||
import MapKit
|
||||
import CoreLocation
|
||||
|
||||
struct DiaryDetailView: View {
|
||||
let dogId: Int
|
||||
@State var entry: DiaryEntry
|
||||
let onChange: () async -> Void
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var photoIndex = 0
|
||||
@State private var showDeleteConfirm = false
|
||||
@State private var isDeleting = false
|
||||
@State private var errorMessage: String?
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
header
|
||||
if let media = entry.mediaItems, !media.isEmpty {
|
||||
gallery(media)
|
||||
}
|
||||
if let text = entry.text, !text.isEmpty {
|
||||
Text(text)
|
||||
.font(.body)
|
||||
.padding(.horizontal, 14)
|
||||
}
|
||||
if let tags = entry.tags, !tags.isEmpty {
|
||||
tagsRow(tags)
|
||||
}
|
||||
if let lat = entry.gpsLat, let lon = entry.gpsLon {
|
||||
locationCard(lat: lat, lon: lon)
|
||||
}
|
||||
deleteButton
|
||||
if let errorMessage {
|
||||
Text(errorMessage).font(.footnote).foregroundStyle(.red).padding(.horizontal, 14)
|
||||
}
|
||||
}
|
||||
.padding(.vertical)
|
||||
}
|
||||
.navigationTitle(entry.titel?.isEmpty == false ? entry.titel! : "Eintrag")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.alert("Eintrag löschen?", isPresented: $showDeleteConfirm) {
|
||||
Button("Abbrechen", role: .cancel) {}
|
||||
Button("Löschen", role: .destructive) { Task { await delete() } }
|
||||
} message: {
|
||||
Text("Dieser Tagebucheintrag und alle zugehörigen Fotos werden endgültig entfernt.")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Header
|
||||
|
||||
private var header: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 8) {
|
||||
if entry.isMilestoneFlag || entry.typ == "meilenstein" {
|
||||
Label("Meilenstein", systemImage: "star.fill")
|
||||
.font(.caption.bold())
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color.orange.opacity(0.2), in: Capsule())
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
if let typ = entry.typ, !typ.isEmpty, typ != "meilenstein" {
|
||||
Text(typ.capitalized)
|
||||
.font(.caption.bold())
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color.accentColor.opacity(0.15), in: Capsule())
|
||||
.foregroundStyle(Color.accentColor)
|
||||
}
|
||||
Spacer()
|
||||
if let datum = entry.datum {
|
||||
Text(DiaryUtil.format(datum))
|
||||
.font(.subheadline.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
if let loc = entry.locationName, !loc.isEmpty {
|
||||
Label(loc, systemImage: "mappin.and.ellipse")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
}
|
||||
|
||||
// MARK: - Photo gallery
|
||||
|
||||
private func gallery(_ media: [DiaryMedia]) -> some View {
|
||||
VStack(spacing: 8) {
|
||||
TabView(selection: $photoIndex) {
|
||||
ForEach(Array(media.enumerated()), id: \.element.id) { idx, m in
|
||||
AsyncImage(url: URL(string: "https://banyaro.app\(m.url)")) { phase in
|
||||
switch phase {
|
||||
case .success(let img): img.resizable().scaledToFit()
|
||||
case .failure: Image(systemName: "photo")
|
||||
.font(.largeTitle)
|
||||
.foregroundStyle(.secondary)
|
||||
default: ProgressView()
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.tag(idx)
|
||||
}
|
||||
}
|
||||
.tabViewStyle(.page(indexDisplayMode: media.count > 1 ? .always : .never))
|
||||
.frame(height: 320)
|
||||
.background(Color.black.opacity(0.04))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
.padding(.horizontal, 14)
|
||||
|
||||
if media.count > 1 {
|
||||
Text("\(photoIndex + 1) / \(media.count)")
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Tags
|
||||
|
||||
private func tagsRow(_ tags: [String]) -> some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 6) {
|
||||
ForEach(tags, id: \.self) { tag in
|
||||
Text("#" + tag)
|
||||
.font(.caption.bold())
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 5)
|
||||
.background(Color.secondary.opacity(0.15), in: Capsule())
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Location
|
||||
|
||||
private func locationCard(lat: Double, lon: Double) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Label("Ort", systemImage: "location.fill")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 14)
|
||||
|
||||
Map(initialPosition: .region(MKCoordinateRegion(
|
||||
center: CLLocationCoordinate2D(latitude: lat, longitude: lon),
|
||||
span: MKCoordinateSpan(latitudeDelta: 0.01, longitudeDelta: 0.01)
|
||||
))) {
|
||||
Annotation(entry.titel ?? "Eintrag",
|
||||
coordinate: CLLocationCoordinate2D(latitude: lat, longitude: lon)) {
|
||||
Image(systemName: "pawprint.circle.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.white, Color.accentColor)
|
||||
.background(.white, in: Circle())
|
||||
}
|
||||
}
|
||||
.frame(height: 180)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.padding(.horizontal, 14)
|
||||
.onTapGesture { openInMaps(lat: lat, lon: lon) }
|
||||
}
|
||||
}
|
||||
|
||||
private func openInMaps(lat: Double, lon: Double) {
|
||||
let item = MKMapItem(placemark: MKPlacemark(
|
||||
coordinate: CLLocationCoordinate2D(latitude: lat, longitude: lon)))
|
||||
item.name = entry.titel ?? "Tagebuch-Eintrag"
|
||||
item.openInMaps(launchOptions: nil)
|
||||
}
|
||||
|
||||
// MARK: - Delete
|
||||
|
||||
private var deleteButton: some View {
|
||||
Button(role: .destructive) {
|
||||
showDeleteConfirm = true
|
||||
} label: {
|
||||
HStack {
|
||||
if isDeleting { ProgressView() }
|
||||
Image(systemName: "trash")
|
||||
Text("Eintrag löschen").bold()
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 44)
|
||||
}
|
||||
.disabled(isDeleting)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.top, 10)
|
||||
}
|
||||
|
||||
private func delete() async {
|
||||
isDeleting = true
|
||||
errorMessage = nil
|
||||
defer { isDeleting = false }
|
||||
do {
|
||||
try await APIClient.shared.delete("/api/dogs/\(dogId)/diary/\(entry.id)")
|
||||
await onChange()
|
||||
dismiss()
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue