Phase 3.6: B+C+D komplett + HealthKit Sync

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.
This commit is contained in:
rene 2026-05-30 11:19:53 +02:00
parent 30e0fbe7ec
commit c01e3d6be7
26 changed files with 978 additions and 28 deletions

View file

@ -1,27 +1,54 @@
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)
.frame(height: 320)
.clipShape(RoundedRectangle(cornerRadius: 16))
.padding(.horizontal)
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")
@ -59,6 +86,85 @@ struct RouteDetailView: View {
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
@ -164,6 +270,17 @@ struct RouteDetailView: View {
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)
@ -176,6 +293,44 @@ struct RouteDetailView: View {
}
}
/// 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