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

@ -0,0 +1,46 @@
import SwiftUI
import UIKit
/// Native camera capture via UIImagePickerController, wrapped for SwiftUI.
/// Falls back to the photo library on the simulator where no camera exists.
struct CameraPicker: UIViewControllerRepresentable {
let onCapture: (Data) -> Void
@Environment(\.dismiss) private var dismiss
func makeCoordinator() -> Coordinator { Coordinator(self) }
func makeUIViewController(context: Context) -> UIImagePickerController {
let picker = UIImagePickerController()
picker.delegate = context.coordinator
if UIImagePickerController.isSourceTypeAvailable(.camera) {
picker.sourceType = .camera
picker.cameraCaptureMode = .photo
} else {
// Simulator has no camera let testing still work via library.
picker.sourceType = .photoLibrary
}
return picker
}
func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {}
final class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
let parent: CameraPicker
init(_ parent: CameraPicker) { self.parent = parent }
func imagePickerController(
_ picker: UIImagePickerController,
didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]
) {
if let image = info[.originalImage] as? UIImage,
let data = image.jpegData(compressionQuality: 0.9) {
parent.onCapture(data)
}
parent.dismiss()
}
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
parent.dismiss()
}
}
}

View file

@ -5,6 +5,7 @@ struct FinishWalkSheet: View {
let points: [GPSPoint]
let durationSeconds: Int
let distanceMeters: Double
let initialPhotos: [Data]
let onDiscard: () -> Void
let onSaved: () -> Void
@ -34,12 +35,14 @@ struct FinishWalkSheet: View {
points: [GPSPoint],
durationSeconds: Int,
distanceMeters: Double,
initialPhotos: [Data] = [],
onDiscard: @escaping () -> Void,
onSaved: @escaping () -> Void
) {
self.points = points
self.durationSeconds = durationSeconds
self.distanceMeters = distanceMeters
self.initialPhotos = initialPhotos
self.onDiscard = onDiscard
self.onSaved = onSaved
@ -47,6 +50,7 @@ struct FinishWalkSheet: View {
formatter.locale = Locale(identifier: "de_DE")
formatter.dateStyle = .medium
_name = State(initialValue: "Gassi am \(formatter.string(from: .now))")
_photoData = State(initialValue: initialPhotos)
}
var body: some View {

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)

View file

@ -1,12 +1,22 @@
import SwiftUI
import SwiftData
import MapKit
struct TrackingView: View {
@Environment(\.modelContext) private var modelContext
@Query private var activeWalks: [ActiveWalk]
@State private var tracker = LocationTracker()
@State private var now: Date = .now
@State private var showFinishSheet = false
@State private var pendingPhotos: [Data] = []
private let ticker = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
@State private var showFinishSheet = false
@State private var showCamera = false
@State private var showResumeDialog = false
@State private var didCheckResume = false
private let clockTicker = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
private let persistTicker = Timer.publish(every: 5, on: .main, in: .common).autoconnect()
var body: some View {
NavigationStack {
@ -20,16 +30,38 @@ struct TrackingView: View {
.navigationTitle("Aufnehmen")
.navigationBarTitleDisplayMode(.inline)
}
.onReceive(ticker) { now = $0 }
.onReceive(clockTicker) { now = $0 }
.onReceive(persistTicker) { _ in persistActive() }
.onAppear { offerResumeIfNeeded() }
.sheet(isPresented: $showFinishSheet) {
FinishWalkSheet(
points: tracker.points,
durationSeconds: durationSeconds,
durationSeconds: tracker.effectiveElapsedSeconds,
distanceMeters: tracker.totalDistanceMeters,
onDiscard: { resetTracker() },
onSaved: { resetTracker() }
initialPhotos: pendingPhotos,
onDiscard: { discardCurrentWalk() },
onSaved: { discardCurrentWalk() }
)
}
.fullScreenCover(isPresented: $showCamera) {
CameraPicker { data in
pendingPhotos.append(data)
}
.ignoresSafeArea()
}
.confirmationDialog(
resumeDialogTitle,
isPresented: $showResumeDialog,
titleVisibility: .visible
) {
Button("Tour fortsetzen") { resumeStoredWalk() }
Button("Jetzt speichern") { loadStoredWalkIntoTracker(thenFinish: true) }
Button("Verwerfen", role: .destructive) { deleteStoredWalk() }
} message: {
if let stored = activeWalks.first {
Text("\(stored.points.count) Punkte aufgezeichnet, gestartet um \(formatTime(stored.startedAt)).")
}
}
}
// MARK: - Active tracking
@ -52,23 +84,43 @@ struct TrackingView: View {
VStack {
statsCard
Spacer()
stopButton
bottomControls
.padding(.bottom, 12)
}
.padding(.horizontal)
cameraOverlayButton
}
}
private var statsCard: some View {
HStack(spacing: 0) {
stat(value: String(format: "%.2f", tracker.totalDistanceMeters / 1000), unit: "km", label: "Distanz")
stat(
value: String(format: "%.2f", tracker.totalDistanceMeters / 1000),
unit: "km",
label: "Distanz"
)
divider
stat(value: formatDuration(durationSeconds), unit: "", label: "Dauer")
stat(value: formatDuration(tracker.effectiveElapsedSeconds), unit: "", label: "Dauer")
divider
stat(value: "\(tracker.points.count)", unit: "", label: "Punkte")
stat(value: "\(pendingPhotos.count)", unit: "", label: "Fotos")
}
.padding()
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16))
.overlay(alignment: .topLeading) {
if tracker.isPaused {
pausedBadge.padding(8)
}
}
}
private var pausedBadge: some View {
Label("Pause", systemImage: "pause.circle.fill")
.font(.caption.bold())
.foregroundStyle(.white)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(.orange, in: Capsule())
}
private var divider: some View {
@ -88,19 +140,54 @@ struct TrackingView: View {
.frame(maxWidth: .infinity)
}
private var stopButton: some View {
Button {
tracker.stop()
showFinishSheet = true
} label: {
private var cameraOverlayButton: some View {
VStack {
Spacer()
HStack {
Image(systemName: "stop.circle.fill")
Text("Aufnahme stoppen").bold()
Spacer()
Button {
showCamera = true
} label: {
Image(systemName: "camera.fill")
.font(.title2)
.foregroundStyle(.white)
.frame(width: 56, height: 56)
.background(Color.accentColor, in: Circle())
.shadow(radius: 4)
}
.padding(.trailing, 20)
.padding(.bottom, 100) // sit above the bottom controls
}
.frame(maxWidth: .infinity, minHeight: 56)
}
.background(.red, in: Capsule())
.foregroundStyle(.white)
.allowsHitTesting(true)
}
private var bottomControls: some View {
HStack(spacing: 12) {
Button {
if tracker.isPaused { tracker.resume() } else { tracker.pause() }
persistActive()
} label: {
Image(systemName: tracker.isPaused ? "play.fill" : "pause.fill")
.font(.title3.bold())
.frame(width: 56, height: 56)
.foregroundStyle(.white)
.background(tracker.isPaused ? Color.accentColor : Color.orange, in: Circle())
}
Button {
tracker.stop()
showFinishSheet = true
} label: {
HStack {
Image(systemName: "stop.circle.fill")
Text("Stoppen").bold()
}
.frame(maxWidth: .infinity, minHeight: 56)
}
.background(.red, in: Capsule())
.foregroundStyle(.white)
}
}
// MARK: - Start screen
@ -125,7 +212,7 @@ struct TrackingView: View {
}
Button {
tracker.startOrRequest()
startFresh()
} label: {
HStack {
Image(systemName: "play.fill")
@ -154,13 +241,79 @@ struct TrackingView: View {
}
}
// MARK: - Helpers
// MARK: - Walk lifecycle
private var durationSeconds: Int {
guard let startedAt = tracker.startedAt else { return 0 }
return Int(now.timeIntervalSince(startedAt))
private func startFresh() {
// Discard any existing stale ActiveWalk before starting a new one
for w in activeWalks { modelContext.delete(w) }
let walk = ActiveWalk(startedAt: .now)
modelContext.insert(walk)
try? modelContext.save()
pendingPhotos = []
tracker.startOrRequest()
}
private func offerResumeIfNeeded() {
guard !didCheckResume else { return }
didCheckResume = true
if !activeWalks.isEmpty, !tracker.isTracking {
showResumeDialog = true
}
}
private func resumeStoredWalk() {
guard let walk = activeWalks.first else { return }
tracker.restore(
startedAt: walk.startedAt,
points: walk.points,
accumulatedPausedSeconds: walk.accumulatedPausedSeconds,
lastUpdate: walk.lastUpdate,
wasPaused: walk.isPaused
)
pendingPhotos = []
}
private func loadStoredWalkIntoTracker(thenFinish: Bool) {
guard let walk = activeWalks.first else { return }
// Mirror walk state into tracker without actually starting GPS we
// just need the data for FinishWalkSheet.
tracker.restore(
startedAt: walk.startedAt,
points: walk.points,
accumulatedPausedSeconds: walk.accumulatedPausedSeconds,
lastUpdate: walk.lastUpdate,
wasPaused: true // prevent automatic GPS restart
)
tracker.stop()
pendingPhotos = []
if thenFinish {
showFinishSheet = true
}
}
private func deleteStoredWalk() {
for w in activeWalks { modelContext.delete(w) }
try? modelContext.save()
}
private func discardCurrentWalk() {
// Called after FinishWalkSheet onSaved or onDiscard. Resets state.
deleteStoredWalk()
tracker = LocationTracker()
pendingPhotos = []
}
private func persistActive() {
guard tracker.isTracking, let walk = activeWalks.first else { return }
walk.points = tracker.points
walk.accumulatedPausedSeconds = tracker.accumulatedPausedSeconds
walk.pausedAt = tracker.isPaused ? (tracker.pausedAt ?? .now) : nil
walk.lastUpdate = .now
try? modelContext.save()
}
// MARK: - Helpers
private func formatDuration(_ seconds: Int) -> String {
let h = seconds / 3600
let m = (seconds % 3600) / 60
@ -169,8 +322,16 @@ struct TrackingView: View {
return String(format: "%d:%02d", m, s)
}
private func resetTracker() {
// Fresh tracker for the next walk; old `points` arrays are released with it.
tracker = LocationTracker()
private func formatTime(_ date: Date) -> String {
let f = DateFormatter()
f.locale = Locale(identifier: "de_DE")
f.dateStyle = .short
f.timeStyle = .short
return f.string(from: date)
}
private var resumeDialogTitle: String {
guard let stored = activeWalks.first else { return "" }
return "Tour nicht gespeichert (\(formatTime(stored.startedAt)))"
}
}