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..= 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)") } }