banyaro-ios/BanYaroGo/Tracking/LocationTracker.swift
rene 5473bbf41f 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.
2026-05-30 10:52:15 +02:00

186 lines
5.7 KiB
Swift

import Foundation
import Observation
import CoreLocation
/// Wraps CLLocationManager for live Gassi-Tracking with background updates.
@Observable
@MainActor
final class LocationTracker: NSObject, CLLocationManagerDelegate {
private let manager = CLLocationManager()
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
private var pendingStart: Bool = false
override init() {
self.authorizationStatus = manager.authorizationStatus
super.init()
manager.delegate = self
manager.desiredAccuracy = kCLLocationAccuracyBestForNavigation
manager.distanceFilter = 5
manager.activityType = .fitness
manager.pausesLocationUpdatesAutomatically = false
}
// MARK: - Lifecycle
func startOrRequest() {
permissionDenied = false
switch authorizationStatus {
case .notDetermined:
pendingStart = true
manager.requestWhenInUseAuthorization()
case .denied, .restricted:
permissionDenied = true
case .authorizedWhenInUse, .authorizedAlways:
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
}
// 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
for i in 1..<points.count {
let a = CLLocation(latitude: points[i - 1].lat, longitude: points[i - 1].lon)
let b = CLLocation(latitude: points[i].lat, longitude: points[i].lon)
total += b.distance(from: a)
}
return total
}
// MARK: - Internal
private func beginFresh() {
points = []
startedAt = .now
pausedAt = nil
accumulatedPausedSeconds = 0
isPaused = false
isTracking = true
beginUpdates()
if authorizationStatus == .authorizedWhenInUse {
manager.requestAlwaysAuthorization()
}
}
private func beginUpdates() {
manager.allowsBackgroundLocationUpdates = true
manager.showsBackgroundLocationIndicator = true
manager.startUpdatingLocation()
}
// MARK: - CLLocationManagerDelegate
nonisolated func locationManager(
_ manager: CLLocationManager,
didUpdateLocations locations: [CLLocation]
) {
let newPoints = locations
.filter { $0.horizontalAccuracy >= 0 && $0.horizontalAccuracy < 30 }
.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)
}
}
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
let status = manager.authorizationStatus
Task { @MainActor in
self.authorizationStatus = status
switch status {
case .denied, .restricted:
self.pendingStart = false
self.permissionDenied = true
case .authorizedWhenInUse, .authorizedAlways:
if self.pendingStart {
self.pendingStart = false
self.beginFresh()
}
default:
break
}
}
}
nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
print("LocationTracker error: \(error)")
}
}