diff --git a/BanYaroGo-Info.plist b/BanYaroGo-Info.plist
index 5fc0d71..c03d2c8 100644
--- a/BanYaroGo-Info.plist
+++ b/BanYaroGo-Info.plist
@@ -48,6 +48,8 @@
Wir brauchen deinen Standort, um deine Gassi-Tour als Karte aufzuzeichnen.
NSLocationAlwaysAndWhenInUseUsageDescription
Damit deine Gassi-Tour auch bei gesperrtem Bildschirm weiter aufgezeichnet wird, brauchen wir Standortzugriff im Hintergrund.
+ NSCameraUsageDescription
+ Für Fotos während deiner Gassi-Tour brauchen wir Zugriff auf die Kamera.
UIBackgroundModes
location
diff --git a/BanYaroGo/BanYaroGoApp.swift b/BanYaroGo/BanYaroGoApp.swift
index 7423f0c..f4a9338 100644
--- a/BanYaroGo/BanYaroGoApp.swift
+++ b/BanYaroGo/BanYaroGoApp.swift
@@ -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)
}
}
diff --git a/BanYaroGo/Tracking/ActiveWalk.swift b/BanYaroGo/Tracking/ActiveWalk.swift
new file mode 100644
index 0000000..49fc78e
--- /dev/null
+++ b/BanYaroGo/Tracking/ActiveWalk.swift
@@ -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 }
+}
diff --git a/BanYaroGo/Tracking/LocationTracker.swift b/BanYaroGo/Tracking/LocationTracker.swift
index d19af16..b9f8407 100644
--- a/BanYaroGo/Tracking/LocationTracker.swift
+++ b/BanYaroGo/Tracking/LocationTracker.swift
@@ -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)")
}
}
diff --git a/BanYaroGo/Views/CameraPicker.swift b/BanYaroGo/Views/CameraPicker.swift
new file mode 100644
index 0000000..99e6f29
--- /dev/null
+++ b/BanYaroGo/Views/CameraPicker.swift
@@ -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()
+ }
+ }
+}
diff --git a/BanYaroGo/Views/FinishWalkSheet.swift b/BanYaroGo/Views/FinishWalkSheet.swift
index 92d5e53..a67ed3f 100644
--- a/BanYaroGo/Views/FinishWalkSheet.swift
+++ b/BanYaroGo/Views/FinishWalkSheet.swift
@@ -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 {
diff --git a/BanYaroGo/Views/RouteDetailView.swift b/BanYaroGo/Views/RouteDetailView.swift
index 12a9f54..b33b1a2 100644
--- a/BanYaroGo/Views/RouteDetailView.swift
+++ b/BanYaroGo/Views/RouteDetailView.swift
@@ -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)
diff --git a/BanYaroGo/Views/TrackingView.swift b/BanYaroGo/Views/TrackingView.swift
index 46c0bb3..e764a71 100644
--- a/BanYaroGo/Views/TrackingView.swift
+++ b/BanYaroGo/Views/TrackingView.swift
@@ -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)))"
}
}
diff --git a/icon/icon-full.png b/icon/icon-full.png
new file mode 100644
index 0000000..cc36708
Binary files /dev/null and b/icon/icon-full.png differ