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

@ -48,6 +48,8 @@
<string>Wir brauchen deinen Standort, um deine Gassi-Tour als Karte aufzuzeichnen.</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>Damit deine Gassi-Tour auch bei gesperrtem Bildschirm weiter aufgezeichnet wird, brauchen wir Standortzugriff im Hintergrund.</string>
<key>NSCameraUsageDescription</key>
<string>Für Fotos während deiner Gassi-Tour brauchen wir Zugriff auf die Kamera.</string>
<key>UIBackgroundModes</key>
<array>
<string>location</string>

View file

@ -1,4 +1,5 @@
import SwiftUI
import SwiftData
@main
struct BanYaroGoApp: App {
@ -9,5 +10,6 @@ struct BanYaroGoApp: App {
RootView()
.environment(auth)
}
.modelContainer(for: ActiveWalk.self)
}
}

View file

@ -0,0 +1,33 @@
import Foundation
import SwiftData
/// Persisted state of an in-progress walk. Lives in SwiftData so a walk
/// survives an app crash or kill on next launch the TrackingView offers
/// the user to resume, save, or discard.
@Model
final class ActiveWalk {
var startedAt: Date
var lastUpdate: Date
var pausedAt: Date?
var accumulatedPausedSeconds: Int
private var pointsData: Data
init(startedAt: Date = .now) {
self.startedAt = startedAt
self.lastUpdate = startedAt
self.pausedAt = nil
self.accumulatedPausedSeconds = 0
self.pointsData = Data("[]".utf8)
}
var points: [GPSPoint] {
get {
(try? JSONDecoder().decode([GPSPoint].self, from: pointsData)) ?? []
}
set {
pointsData = (try? JSONEncoder().encode(newValue)) ?? Data("[]".utf8)
}
}
var isPaused: Bool { pausedAt != nil }
}

View file

@ -3,11 +3,6 @@ import Observation
import CoreLocation
/// Wraps CLLocationManager for live Gassi-Tracking with background updates.
///
/// Usage:
/// 1. `startOrRequest()` handles permission flow + starts recording when granted.
/// 2. Watch `isTracking`, `points`, `totalDistanceMeters`, `startedAt`.
/// 3. `stop()` to end the recording and inspect the final `points`.
@Observable
@MainActor
final class LocationTracker: NSObject, CLLocationManagerDelegate {
@ -15,12 +10,13 @@ final class LocationTracker: NSObject, CLLocationManagerDelegate {
var points: [GPSPoint] = []
var isTracking: Bool = false
var isPaused: Bool = false
var startedAt: Date?
var pausedAt: Date?
var accumulatedPausedSeconds: Int = 0
var authorizationStatus: CLAuthorizationStatus
var permissionDenied: Bool = false
/// Set when the user asked to start but we're still waiting for the system
/// permission prompt. The delegate auto-starts as soon as access is granted.
private var pendingStart: Bool = false
override init() {
@ -28,12 +24,12 @@ final class LocationTracker: NSObject, CLLocationManagerDelegate {
super.init()
manager.delegate = self
manager.desiredAccuracy = kCLLocationAccuracyBestForNavigation
manager.distanceFilter = 5 // meters
manager.distanceFilter = 5
manager.activityType = .fitness
manager.pausesLocationUpdatesAutomatically = false
}
// MARK: - Public API
// MARK: - Lifecycle
func startOrRequest() {
permissionDenied = false
@ -44,19 +40,79 @@ final class LocationTracker: NSObject, CLLocationManagerDelegate {
case .denied, .restricted:
permissionDenied = true
case .authorizedWhenInUse, .authorizedAlways:
beginTracking()
beginFresh()
@unknown default:
permissionDenied = true
}
}
/// Restore an in-progress walk that was interrupted by an app restart.
/// Treats the elapsed offline time as pause time so the active-tracking
/// counter doesn't jump.
func restore(
startedAt: Date,
points: [GPSPoint],
accumulatedPausedSeconds: Int,
lastUpdate: Date,
wasPaused: Bool
) {
self.startedAt = startedAt
self.points = points
// The offline gap counts as paused time.
let offlineGap = max(0, Int(Date.now.timeIntervalSince(lastUpdate)))
self.accumulatedPausedSeconds = accumulatedPausedSeconds + offlineGap
self.isTracking = true
if wasPaused {
self.isPaused = true
self.pausedAt = nil // the previous pause is rolled into accumulated
} else {
self.isPaused = false
beginUpdates()
}
}
func pause() {
guard isTracking, !isPaused else { return }
isPaused = true
pausedAt = .now
manager.stopUpdatingLocation()
}
func resume() {
guard isPaused else { return }
if let pausedAt {
accumulatedPausedSeconds += Int(Date.now.timeIntervalSince(pausedAt))
}
pausedAt = nil
isPaused = false
beginUpdates()
}
func stop() {
if isPaused, let pausedAt {
accumulatedPausedSeconds += Int(Date.now.timeIntervalSince(pausedAt))
}
isPaused = false
pausedAt = nil
manager.stopUpdatingLocation()
manager.allowsBackgroundLocationUpdates = false
isTracking = false
}
/// Total distance walked so far, in meters.
// MARK: - Derived state
/// Active tracking time in seconds, with pauses already removed.
var effectiveElapsedSeconds: Int {
guard let startedAt else { return 0 }
let total = Int(Date.now.timeIntervalSince(startedAt))
var paused = accumulatedPausedSeconds
if isPaused, let pausedAt {
paused += Int(Date.now.timeIntervalSince(pausedAt))
}
return max(0, total - paused)
}
var totalDistanceMeters: Double {
guard points.count >= 2 else { return 0 }
var total: Double = 0
@ -70,21 +126,25 @@ final class LocationTracker: NSObject, CLLocationManagerDelegate {
// MARK: - Internal
private func beginTracking() {
private func beginFresh() {
points = []
startedAt = .now
manager.allowsBackgroundLocationUpdates = true
manager.showsBackgroundLocationIndicator = true
manager.startUpdatingLocation()
pausedAt = nil
accumulatedPausedSeconds = 0
isPaused = false
isTracking = true
// If we only have "when in use", silently try to escalate to "always".
// System will show the secondary prompt on its own schedule.
beginUpdates()
if authorizationStatus == .authorizedWhenInUse {
manager.requestAlwaysAuthorization()
}
}
private func beginUpdates() {
manager.allowsBackgroundLocationUpdates = true
manager.showsBackgroundLocationIndicator = true
manager.startUpdatingLocation()
}
// MARK: - CLLocationManagerDelegate
nonisolated func locationManager(
@ -96,6 +156,7 @@ final class LocationTracker: NSObject, CLLocationManagerDelegate {
.map { GPSPoint(lat: $0.coordinate.latitude, lon: $0.coordinate.longitude, alt: $0.altitude) }
guard !newPoints.isEmpty else { return }
Task { @MainActor in
guard self.isTracking, !self.isPaused else { return }
self.points.append(contentsOf: newPoints)
}
}
@ -111,7 +172,7 @@ final class LocationTracker: NSObject, CLLocationManagerDelegate {
case .authorizedWhenInUse, .authorizedAlways:
if self.pendingStart {
self.pendingStart = false
self.beginTracking()
self.beginFresh()
}
default:
break
@ -120,8 +181,6 @@ final class LocationTracker: NSObject, CLLocationManagerDelegate {
}
nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
// Transient errors (e.g. no fix yet) are common we just keep listening.
// Log to console so we can see during development.
print("LocationTracker error: \(error)")
}
}

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,20 +140,55 @@ struct TrackingView: View {
.frame(maxWidth: .infinity)
}
private var stopButton: some View {
private var cameraOverlayButton: some View {
VStack {
Spacer()
HStack {
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
}
}
.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("Aufnahme stoppen").bold()
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)))"
}
}

BIN
icon/icon-full.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB