banyaro-ios/BanYaroGo/Views/RouteDetailView.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

353 lines
12 KiB
Swift

import SwiftUI
import SwiftData
import PhotosUI
struct RouteDetailView: View {
let routeId: Int
let fallbackName: String
@Environment(\.dismiss) private var dismiss
@Environment(AuthSession.self) private var auth
@Environment(\.modelContext) private var ctx
@Query private var allPhotoLocations: [PhotoLocation]
@State private var detail: RouteDetail?
@State private var isLoading = false
@State private var errorMessage: String?
@State private var tappedPhotoUrl: String?
@State private var newPhotoSelection: [PhotosPickerItem] = []
@State private var isUploadingPhoto = false
@State private var photoUploadProgress: (done: Int, total: Int) = (0, 0)
@State private var photoErrorMessage: String?
@State private var showEditSheet = false
@State private var showDeleteAlert = false
@State private var isDeleting = false
@State private var shareItems: [Any]?
@State private var isGeneratingShareImage = false
private var isOwn: Bool {
guard let detail, let myId = auth.profile?.id else { return false }
return detail.userId == myId
}
private var photoLocations: [PhotoLocation] {
allPhotoLocations.filter { $0.routeId == routeId }
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
if let detail {
MiniRouteMap(
track: detail.gpsTrack,
lineWidth: 4,
photoLocations: photoLocations,
onPhotoTap: { loc in tappedPhotoUrl = loc.photoUrl }
)
.frame(height: 320)
.clipShape(RoundedRectangle(cornerRadius: 16))
.padding(.horizontal)
HStack(spacing: 12) {
StatTile(value: formatKm(detail.distanzKm), label: "Distanz", icon: "ruler")
StatTile(value: formatMin(detail.dauerMin), label: "Dauer", icon: "clock")
StatTile(value: "\(detail.gpsTrack.count)", label: "Punkte", icon: "point.3.connected.trianglepath.dotted")
}
.padding(.horizontal)
if let beschreibung = detail.beschreibung, !beschreibung.isEmpty {
Text(beschreibung)
.font(.body)
.padding(.horizontal)
}
photosSection(for: detail)
.padding(.horizontal)
Spacer(minLength: 24)
} else if isLoading {
ProgressView().padding(.top, 80)
} else if let error = errorMessage {
ContentUnavailableView(
"Fehler",
systemImage: "exclamationmark.triangle",
description: Text(error)
)
.padding(.top, 60)
}
}
}
.navigationTitle(detail?.name ?? fallbackName)
.navigationBarTitleDisplayMode(.inline)
.task { await load() }
.onChange(of: newPhotoSelection) { _, items in
guard !items.isEmpty else { return }
Task { await uploadSelected(items: items) }
}
.toolbar {
if let detail {
ToolbarItem(placement: .topBarTrailing) {
Menu {
Button { Task { await shareAsImage() } } label: {
Label("Als Bild teilen", systemImage: "photo")
}
.disabled(isGeneratingShareImage)
Button { shareAsGPX() } label: {
Label("Als GPX-Datei teilen", systemImage: "doc.text")
}
if isOwn {
Divider()
Button {
showEditSheet = true
} label: {
Label("Bearbeiten", systemImage: "pencil")
}
Button(role: .destructive) {
showDeleteAlert = true
} label: {
Label("Löschen", systemImage: "trash")
}
}
} label: {
if isGeneratingShareImage {
ProgressView()
} else {
Image(systemName: "ellipsis.circle")
}
}
.disabled(isDeleting)
.accessibilityIdentifier("routeDetailMenu_\(detail.id)")
}
}
}
.sheet(isPresented: $showEditSheet) {
if let detail {
EditRouteSheet(detail: detail) { updated in
self.detail = updated
}
}
}
.alert("Tour wirklich löschen?", isPresented: $showDeleteAlert) {
Button("Abbrechen", role: .cancel) {}
Button("Löschen", role: .destructive) { Task { await deleteRoute() } }
} message: {
Text("Die Tour wird unwiderruflich gelöscht — auch alle Fotos.")
}
.sheet(item: Binding(
get: { tappedPhotoUrl.map(IdentifiedURL.init) },
set: { tappedPhotoUrl = $0?.value }
)) { item in
PhotoViewerSheet(path: item.value)
}
.sheet(isPresented: Binding(
get: { shareItems != nil },
set: { if !$0 { shareItems = nil } }
)) {
if let items = shareItems {
ShareSheet(items: items)
}
}
}
private func shareAsImage() async {
guard let detail else { return }
isGeneratingShareImage = true
defer { isGeneratingShareImage = false }
if let img = await RouteShareImage.render(detail: detail) {
shareItems = [img]
}
}
private func shareAsGPX() {
guard let detail else { return }
if let url = GPXExporter.write(detail: detail) {
shareItems = [url]
}
}
@ViewBuilder
private func photosSection(for detail: RouteDetail) -> some View {
let urls = detail.fotoUrls ?? []
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(urls.isEmpty ? "Fotos" : "Fotos (\(urls.count))")
.font(.headline)
Spacer()
PhotosPicker(
selection: $newPhotoSelection,
maxSelectionCount: 5,
matching: .images
) {
Label("Foto hinzufügen", systemImage: "photo.badge.plus")
.font(.subheadline)
}
.disabled(isUploadingPhoto)
}
if isUploadingPhoto {
HStack(spacing: 8) {
ProgressView()
Text("Lade \(photoUploadProgress.done + 1)/\(photoUploadProgress.total)")
.font(.caption)
.foregroundStyle(.secondary)
}
}
if let photoErrorMessage {
Text(photoErrorMessage)
.font(.caption)
.foregroundStyle(.red)
}
if !urls.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
ForEach(urls, id: \.self) { path in
photoThumb(path)
}
}
}
} else if !isUploadingPhoto {
Text("Noch keine Fotos. Über den Button oben kannst du welche hinzufügen.")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
private func photoThumb(_ path: String) -> some View {
CachedAsyncImage(path: path) { img in
img.resizable().scaledToFill()
} placeholder: {
Rectangle().fill(.gray.opacity(0.15))
}
.frame(width: 160, height: 160)
.clipShape(RoundedRectangle(cornerRadius: 10))
}
private func load() async {
// 1) Cache-first: gespeicherten Detail-Stand zeigen (auch offline)
if detail == nil, let cached = OfflineCache.cachedRouteDetail(id: routeId, in: ctx) {
detail = cached
}
isLoading = (detail == nil)
errorMessage = nil
defer { isLoading = false }
// 2) Netzwerk Cache aktualisieren; bei Fehler bleibt der Cache stehen
do {
let fetched: RouteDetail = try await APIClient.shared.get("/api/routes/\(routeId)")
detail = fetched
OfflineCache.upsertRouteDetail(fetched, in: ctx)
let paths = fetched.fotoUrls ?? []
Task { await OfflineCache.prefetchImages(paths: paths, in: ctx) }
} catch {
if detail == nil { errorMessage = error.localizedDescription }
}
}
private func uploadSelected(items: [PhotosPickerItem]) async {
isUploadingPhoto = true
photoErrorMessage = nil
photoUploadProgress = (0, items.count)
defer {
isUploadingPhoto = false
newPhotoSelection = []
}
for (index, item) in items.enumerated() {
photoUploadProgress = (index, items.count)
guard let raw = try? await item.loadTransferable(type: Data.self) else { continue }
let resized = ImageResize.resizedJPEG(from: raw)
do {
try await APIClient.shared.uploadFile(
"/api/routes/\(routeId)/photo",
filename: "photo_\(Int(Date.now.timeIntervalSince1970))_\(index + 1).jpg",
data: resized
)
} catch {
photoErrorMessage = "Foto \(index + 1) konnte nicht hochgeladen werden: \(error.localizedDescription)"
break
}
}
// Refresh detail to pick up the new foto_urls.
await load()
}
private func deleteRoute() async {
isDeleting = true
defer { isDeleting = false }
do {
try await APIClient.shared.delete("/api/routes/\(routeId)")
dismiss()
} catch {
errorMessage = error.localizedDescription
}
}
private func formatKm(_ km: Double?) -> String {
guard let km else { return "" }
return String(format: "%.2f km", km)
}
private func formatMin(_ mins: Int?) -> String {
guard let mins else { return "" }
if mins >= 60 { return "\(mins / 60) h \(mins % 60) min" }
return "\(mins) min"
}
}
/// Wrapper so we can use sheet(item:) with a plain String URL path.
private struct IdentifiedURL: Identifiable {
let value: String
var id: String { value }
}
private struct PhotoViewerSheet: View {
let path: String
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
ZStack {
Color.black.ignoresSafeArea()
if !path.isEmpty {
CachedAsyncImage(path: path) { img in
img.resizable().scaledToFit()
} placeholder: {
ProgressView().tint(.white)
}
}
}
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button { dismiss() } label: { Image(systemName: "xmark") }
.tint(.white)
}
}
}
}
}
private struct StatTile: View {
let value: String
let label: String
let icon: String
var body: some View {
VStack(spacing: 6) {
Image(systemName: icon)
.foregroundStyle(Color.accentColor)
Text(value)
.font(.headline)
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(.background.secondary, in: RoundedRectangle(cornerRadius: 12))
}
}