D.10 401-Handling: APIError.unauthorized, NotificationCenter-Bridge, AuthSession.logout() bei 401 → User landet wieder im Login D.12 PWA-Deep-Links: Settings-Section mit Forum/Hunde/Walks/Settings öffnet Safari per https://banyaro.app/#fragment B.4 Auto-Pause: 2-min-Inaktivität → isAutoPaused, automatischer Resume bei nächstem GPS-Update. Settings-Toggle, im UI eigenes Badge "Auto-Pause" (grau vs. Pause orange). C.7 Edit/Delete: RouteUpdateBody + APIClient.patch + APIClient.delete, EditRouteSheet (Name/Beschreibung/Public), Menu in Toolbar (nur eigene Touren), Alert für Delete. C.9 Statistik-Tab: neuer Tab "Statistik" zwischen Hunde und Mehr. Filtert /api/routes auf meine Touren, rechnet Woche/Monat/Allzeit (Distanz, Dauer, Touren), Längste Tour, aktuelle Streak (Tage in Folge). B.5 Walk-Review: Map-Header an die Spitze des FinishWalkSheet-Forms. B.6 Geo-Fotos: CapturedPhoto (Data + GPSPoint?), PhotoLocation @Model in SwiftData. Kamera während Walk taggt mit tracker.points.last. Nach Upload: foto_url aus Response → PhotoLocation persistiert. MiniRouteMap rendert Annotations mit Tap-Callback, PhotoViewerSheet zeigt Foto fullscreen. C.8 Share PNG+GPX: RouteShareImage (MKMapSnapshotter + Polyline overlay + SwiftUI ShareCard via ImageRenderer), GPXExporter (Tempfile mit XML), ShareSheet (UIActivityViewController-Wrapper), Menu in Route-Toolbar. D.11 Icon-Varianten: AppIcon-Dark (0.45 Brightness), AppIcon-Tinted (Grayscale + Kontrastverstärkung), Contents.json mit appearance entries. A.2 HealthKit: BanYaroGo.entitlements (com.apple.developer.healthkit), NSHealthShare/UpdateUsageDescription. WalkHealthSync.shared mit HKWorkoutBuilder (.walking) + HKWorkoutRouteBuilder, Timestamps gleichmäßig über Walk-Dauer verteilt. Settings-Toggle mit Permission-Request.
353 lines
12 KiB
Swift
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
|
|
@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 {
|
|
let url = URL(string: "https://banyaro.app\(path)")
|
|
return AsyncImage(url: url) { phase in
|
|
switch phase {
|
|
case .success(let img):
|
|
img.resizable().scaledToFill()
|
|
default:
|
|
Rectangle().fill(.gray.opacity(0.15))
|
|
}
|
|
}
|
|
.frame(width: 160, height: 160)
|
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
|
}
|
|
|
|
private func load() async {
|
|
isLoading = true
|
|
errorMessage = nil
|
|
defer { isLoading = false }
|
|
do {
|
|
detail = try await APIClient.shared.get("/api/routes/\(routeId)")
|
|
} catch {
|
|
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 let url = URL(string: "https://banyaro.app\(path)") {
|
|
AsyncImage(url: url) { phase in
|
|
switch phase {
|
|
case .success(let img):
|
|
img.resizable().scaledToFit()
|
|
case .failure:
|
|
ContentUnavailableView("Foto nicht ladbar", systemImage: "photo.badge.exclamationmark")
|
|
.foregroundStyle(.white)
|
|
default:
|
|
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))
|
|
}
|
|
}
|