banyaro-ios/BanYaroGo/Views/DiaryDetailView.swift
rene a2646a18ef 1.1: Offline-Cache + Outbox für Touren/Tagebuch, WeatherKit-Fix, Aufräumen
App-Review-Fix (Guideline 2.1 WeatherKit):
- OneShotLocation: deterministisches async resolve() mit 10s-Timeout statt
  onChange-Lauschen; WetterView lädt bei fehlendem Standort einen Berlin-Fallback
  → kein ewiges Hängen bei "Hole Standort…", WeatherKit ist immer sichtbar.

Offline-Lesen (SwiftData):
- CachedRoute/CachedDiaryEntry/CachedImage + CachedAsyncImage: Touren, Tagebuch
  und Fotos werden cache-first geladen und sind offline verfügbar.
- Cache wird bei Logout/401 geleert (RootView), kein Durchschimmern fremder User.

Offline-Speichern (Outbox):
- PendingRoute/PendingRoutePhoto: Tour inkl. unterwegs hinzugefügter Fotos wird
  offline lokal gesichert und automatisch hochgeladen (Touren-Tab + App-Start).
- Touren-Liste zeigt offline gesicherte Touren mit "wird hochgeladen"-Badge.

FinishWalkSheet:
- Dismiss-Schutz: Speichern-Dialog lässt sich nicht mehr wegwischen — eine
  aufgezeichnete Tour geht nicht mehr durch Runterwischen verloren.

Wetter:
- Ortslabel (Reverse-Geocoding; Fallback "Berlin · Näherung").
- Saubere Offline-Meldung statt rohem networkError.

Aufräumen:
- Doppeltes "Gassi-Treffen" im Mehr-Tab entfernt.
- Veraltete Phase-1/2-Texte neu getextet.
- Tote DogsListView gelöscht (Hund-Wechsel läuft über den Heim-Picker).
2026-06-02 19:37:30 +02:00

199 lines
7.1 KiB
Swift

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
CachedAsyncImage(path: m.url) { img in
img.resizable().scaledToFit()
} placeholder: {
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
}
}
}