Phase 3.5: Pause/Resume, SwiftData-Persistenz, Kamera-Capture, Fotos zu bestehender Tour

LocationTracker:
- isPaused, pausedAt, accumulatedPausedSeconds
- pause()/resume()/restore() Methoden
- effectiveElapsedSeconds rechnet Pausen raus
- restore() für nach App-Crash: Offline-Lücke wird als Pause gezählt

ActiveWalk @Model (SwiftData):
- startedAt, lastUpdate, pausedAt, accumulatedPausedSeconds, pointsData
- Container in BanYaroGoApp registriert

TrackingView:
- Persistenz alle 5s via Timer
- confirmationDialog beim Erscheinen wenn ActiveWalk vorhanden:
  Fortsetzen / Jetzt speichern / Verwerfen
- Pause/Resume-Button + Stop-Button
- Floating Kamera-Button rechts unten
- Foto-Counter in der Stats-Karte
- Pause-Badge oben links bei Pause

CameraPicker: UIImagePickerController-Wrapper (Fallback auf Library im Simulator).

FinishWalkSheet: initialPhotos: [Data] für Kamera-Fotos während Tour.

RouteDetailView: PhotosPicker zum Hinzufügen von Fotos zu bestehender Tour,
sequentieller Upload mit Progress, Detail wird nach Upload refreshed.

NSCameraUsageDescription in BanYaroGo-Info.plist.
This commit is contained in:
rene 2026-05-30 10:52:15 +02:00
parent e27fa39620
commit 5473bbf41f
9 changed files with 445 additions and 61 deletions

View file

@ -1,4 +1,5 @@
import SwiftUI
import PhotosUI
struct RouteDetailView: View {
let routeId: Int
@ -8,6 +9,11 @@ struct RouteDetailView: View {
@State private var isLoading = false
@State private var errorMessage: 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?
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
@ -30,19 +36,8 @@ struct RouteDetailView: View {
.padding(.horizontal)
}
if let urls = detail.fotoUrls, !urls.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Text("Fotos").font(.headline)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
ForEach(urls, id: \.self) { path in
photoThumb(path)
}
}
}
}
photosSection(for: detail)
.padding(.horizontal)
}
Spacer(minLength: 24)
} else if isLoading {
@ -60,6 +55,60 @@ struct RouteDetailView: View {
.navigationTitle(detail?.name ?? fallbackName)
.navigationBarTitleDisplayMode(.inline)
.task { await load() }
.onChange(of: newPhotoSelection) { _, items in
guard !items.isEmpty else { return }
Task { await uploadSelected(items: items) }
}
}
@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 {
@ -87,6 +136,34 @@ struct RouteDetailView: View {
}
}
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 formatKm(_ km: Double?) -> String {
guard let km else { return "" }
return String(format: "%.2f km", km)