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.
186 lines
5.7 KiB
Swift
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)")
|
|
}
|
|
}
|