// ── Location Trigger Manager ────────────────────────────────── // CoreLocation geofencing for location-based timer triggers // Privacy-first: all processing on-device, no server tracking import Foundation import CoreLocation import Combine @MainActor final class LocationTriggerManager: NSObject, ObservableObject { static let shared = LocationTriggerManager() @Published var authorizationStatus: CLAuthorizationStatus = .notDetermined @Published var savedLocations: [SavedLocation] = [] @Published var locationTriggers: [LocationTrigger] = [] private let locationManager = CLLocationManager() private let locationsKey = "chronomind-saved-locations" private let triggersKey = "chronomind-location-triggers" override private init() { super.init() locationManager.delegate = self locationManager.desiredAccuracy = kCLLocationAccuracyHundredMeters authorizationStatus = locationManager.authorizationStatus loadData() } // MARK: - Authorization func requestAlwaysAuthorization() { locationManager.requestAlwaysAuthorization() } func requestWhenInUseAuthorization() { locationManager.requestWhenInUseAuthorization() } // MARK: - Saved Locations func addLocation(_ location: SavedLocation) { savedLocations.append(location) saveData() } func removeLocation(_ id: String) { // Remove associated triggers first let triggerIds = locationTriggers.filter { $0.locationId == id }.map(\.id) for triggerId in triggerIds { removeTrigger(triggerId) } savedLocations.removeAll { $0.id == id } saveData() } func updateLocation(_ location: SavedLocation) { if let index = savedLocations.firstIndex(where: { $0.id == location.id }) { savedLocations[index] = location saveData() } } // MARK: - Triggers func addTrigger(_ trigger: LocationTrigger) { locationTriggers.append(trigger) startMonitoring(trigger) saveData() } func removeTrigger(_ id: String) { if let trigger = locationTriggers.first(where: { $0.id == id }), let location = savedLocations.first(where: { $0.id == trigger.locationId }) { let region = CLCircularRegion( center: CLLocationCoordinate2D(latitude: location.latitude, longitude: location.longitude), radius: location.radius, identifier: trigger.id ) locationManager.stopMonitoring(for: region) } locationTriggers.removeAll { $0.id == id } saveData() } // MARK: - Monitoring private func startMonitoring(_ trigger: LocationTrigger) { guard let location = savedLocations.first(where: { $0.id == trigger.locationId }) else { return } let region = CLCircularRegion( center: CLLocationCoordinate2D(latitude: location.latitude, longitude: location.longitude), radius: location.radius, identifier: trigger.id ) region.notifyOnEntry = trigger.triggerType == .enter region.notifyOnExit = trigger.triggerType == .exit locationManager.startMonitoring(for: region) } func startAllMonitoring() { for trigger in locationTriggers where trigger.enabled { startMonitoring(trigger) } } func stopAllMonitoring() { for region in locationManager.monitoredRegions { locationManager.stopMonitoring(for: region) } } // MARK: - Current Location func getCurrentLocation() async -> CLLocation? { return await withCheckedContinuation { continuation in let delegate = SingleLocationDelegate { location in continuation.resume(returning: location) } let tempManager = CLLocationManager() tempManager.delegate = delegate tempManager.desiredAccuracy = kCLLocationAccuracyBest tempManager.requestLocation() // Hold reference objc_setAssociatedObject(tempManager, "delegate", delegate, .OBJC_ASSOCIATION_RETAIN) } } // MARK: - Persistence private func loadData() { if let data = UserDefaults.standard.data(forKey: locationsKey), let decoded = try? JSONDecoder().decode([SavedLocation].self, from: data) { savedLocations = decoded } if let data = UserDefaults.standard.data(forKey: triggersKey), let decoded = try? JSONDecoder().decode([LocationTrigger].self, from: data) { locationTriggers = decoded } } private func saveData() { if let data = try? JSONEncoder().encode(savedLocations) { UserDefaults.standard.set(data, forKey: locationsKey) } if let data = try? JSONEncoder().encode(locationTriggers) { UserDefaults.standard.set(data, forKey: triggersKey) } } } // MARK: - CLLocationManagerDelegate extension LocationTriggerManager: CLLocationManagerDelegate { nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { Task { @MainActor in authorizationStatus = manager.authorizationStatus if manager.authorizationStatus == .authorizedAlways { startAllMonitoring() } } } nonisolated func locationManager(_ manager: CLLocationManager, didEnterRegion region: CLRegion) { Task { @MainActor in handleRegionEvent(region.identifier, type: .enter) } } nonisolated func locationManager(_ manager: CLLocationManager, didExitRegion region: CLRegion) { Task { @MainActor in handleRegionEvent(region.identifier, type: .exit) } } nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { // Silently handle — location is best-effort } private func handleRegionEvent(_ triggerId: String, type: LocationTriggerType) { guard let trigger = locationTriggers.first(where: { $0.id == triggerId }), trigger.enabled, trigger.triggerType == type else { return } // Post notification for TimerStore to handle NotificationCenter.default.post( name: .chronoMindLocationTriggerFired, object: nil, userInfo: [ "triggerId": triggerId, "triggerType": type.rawValue, "timerLabel": trigger.timerLabel, "timerDuration": trigger.timerDurationSeconds ?? 0, ] ) } } // MARK: - Single Location Delegate Helper private class SingleLocationDelegate: NSObject, CLLocationManagerDelegate { let completion: (CLLocation?) -> Void init(completion: @escaping (CLLocation?) -> Void) { self.completion = completion } func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { completion(locations.first) } func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { completion(nil) } } // MARK: - Models struct SavedLocation: Codable, Identifiable { let id: String var name: String var latitude: Double var longitude: Double var radius: Double // meters, default 100 var icon: String // SF Symbol name init(name: String, latitude: Double, longitude: Double, radius: Double = 100, icon: String = "mappin.circle.fill") { self.id = UUID().uuidString self.name = name self.latitude = latitude self.longitude = longitude self.radius = radius self.icon = icon } static let presets: [(name: String, icon: String)] = [ ("Home", "house.fill"), ("Work", "building.2.fill"), ("Gym", "figure.run"), ("School", "graduationcap.fill"), ] } enum LocationTriggerType: String, Codable, CaseIterable, Identifiable { case enter = "enter" case exit = "exit" var id: String { rawValue } var label: String { switch self { case .enter: return "Arrive" case .exit: return "Leave" } } } struct LocationTrigger: Codable, Identifiable { let id: String let locationId: String let triggerType: LocationTriggerType let timerLabel: String let timerDurationSeconds: TimeInterval? let timerUrgency: UrgencyLevel var enabled: Bool init( locationId: String, triggerType: LocationTriggerType, timerLabel: String, timerDurationSeconds: TimeInterval? = nil, timerUrgency: UrgencyLevel = .standard, enabled: Bool = true ) { self.id = UUID().uuidString self.locationId = locationId self.triggerType = triggerType self.timerLabel = timerLabel self.timerDurationSeconds = timerDurationSeconds self.timerUrgency = timerUrgency self.enabled = enabled } } // MARK: - Notification Names extension Notification.Name { static let chronoMindLocationTriggerFired = Notification.Name("chronoMindLocationTriggerFired") }