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:
parent
30e0fbe7ec
commit
c01e3d6be7
26 changed files with 978 additions and 28 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue