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:
parent
e27fa39620
commit
5473bbf41f
9 changed files with 445 additions and 61 deletions
33
BanYaroGo/Tracking/ActiveWalk.swift
Normal file
33
BanYaroGo/Tracking/ActiveWalk.swift
Normal 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 }
|
||||
}
|
||||
|
|
@ -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)")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue