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 isAutoPaused: Bool = false var startedAt: Date? var pausedAt: Date? var accumulatedPausedSeconds: Int = 0 var accumulatedAutoPausedSeconds: Int = 0 var authorizationStatus: CLAuthorizationStatus var permissionDenied: Bool = false private var pendingStart: Bool = false private var lastPointAt: Date? private var autoPauseStartedAt: Date? 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)) } endAutoPauseIfNeeded() isPaused = false pausedAt = nil manager.stopUpdatingLocation() manager.allowsBackgroundLocationUpdates = false isTracking = false } // MARK: - Derived state /// Active tracking time in seconds, with both manual and auto pauses removed. var effectiveElapsedSeconds: Int { guard let startedAt else { return 0 } let total = Int(Date.now.timeIntervalSince(startedAt)) var paused = accumulatedPausedSeconds + accumulatedAutoPausedSeconds if isPaused, let pausedAt { paused += Int(Date.now.timeIntervalSince(pausedAt)) } if isAutoPaused, let autoPauseStartedAt { paused += Int(Date.now.timeIntervalSince(autoPauseStartedAt)) } return max(0, total - paused) } /// Called from a timer in TrackingView. If no new GPS point arrived in the /// last 2 minutes, auto-pause the duration counter without stopping GPS — /// so we can detect the resume from the next location update. func checkAutoPause() { guard isTracking, !isPaused, !isAutoPaused else { return } let enabled = UserDefaults.standard.object(forKey: "autoPauseEnabled") as? Bool ?? true guard enabled else { return } guard let last = lastPointAt else { return } if Date.now.timeIntervalSince(last) > 120 { isAutoPaused = true autoPauseStartedAt = .now } } private func endAutoPauseIfNeeded() { guard isAutoPaused else { return } if let started = autoPauseStartedAt { accumulatedAutoPausedSeconds += Int(Date.now.timeIntervalSince(started)) } isAutoPaused = false autoPauseStartedAt = nil } 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 } // If we were auto-paused, the very arrival of a new GPS update // means movement resumed. self.endAutoPauseIfNeeded() self.points.append(contentsOf: newPoints) self.lastPointAt = .now } } 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)") } }