diff --git a/ios/ChronoMind/Shared/Location/LocationTriggerManager.swift b/ios/ChronoMind/Shared/Location/LocationTriggerManager.swift new file mode 100644 index 0000000..877edcf --- /dev/null +++ b/ios/ChronoMind/Shared/Location/LocationTriggerManager.swift @@ -0,0 +1,285 @@ +// ── 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") +} diff --git a/ios/ChronoMind/Shared/Location/TravelTimeManager.swift b/ios/ChronoMind/Shared/Location/TravelTimeManager.swift new file mode 100644 index 0000000..c725366 --- /dev/null +++ b/ios/ChronoMind/Shared/Location/TravelTimeManager.swift @@ -0,0 +1,205 @@ +// ── Travel Time Manager ─────────────────────────────────────── +// MapKit-based travel time estimates for timer pre-warnings +// Auto-adjusts cascade warnings based on real-time travel time + +import Foundation +import MapKit +import Combine + +@MainActor +final class TravelTimeManager: ObservableObject { + static let shared = TravelTimeManager() + + @Published var cachedEstimates: [String: TravelEstimate] = [:] + + private init() {} + + // MARK: - Travel Time Calculation + + /// Calculate travel time between current location and a destination + func estimateTravelTime( + to destination: CLLocationCoordinate2D, + transportType: MKDirectionsTransportType = .automobile, + departureDate: Date? = nil + ) async -> TravelEstimate? { + guard let currentLocation = await LocationTriggerManager.shared.getCurrentLocation() else { + return nil + } + + let request = MKDirections.Request() + request.source = MKMapItem(placemark: MKPlacemark(coordinate: currentLocation.coordinate)) + request.destination = MKMapItem(placemark: MKPlacemark(coordinate: destination)) + request.transportType = transportType + request.departureDate = departureDate + + let directions = MKDirections(request: request) + + do { + let response = try await directions.calculateETA() + let estimate = TravelEstimate( + travelTimeSeconds: response.expectedTravelTime, + distance: response.distance, + transportType: transportType, + calculatedAt: Date(), + departureDate: departureDate ?? Date(), + arrivalDate: Date().addingTimeInterval(response.expectedTravelTime) + ) + + return estimate + } catch { + return nil + } + } + + /// Calculate travel time for a timer with a known destination + func estimateForTimer( + timerId: String, + destination: CLLocationCoordinate2D, + transportType: MKDirectionsTransportType = .automobile, + targetTime: Date + ) async -> TravelEstimate? { + // Calculate when to leave to arrive on time + guard let estimate = await estimateTravelTime( + to: destination, + transportType: transportType, + departureDate: targetTime.addingTimeInterval(-3600) // estimate departure ~1h before + ) else { return nil } + + var result = estimate + result.timerId = timerId + result.leaveByTime = targetTime.addingTimeInterval(-estimate.travelTimeSeconds) + + // Cache it + cachedEstimates[timerId] = result + + return result + } + + /// Generate adjusted cascade intervals based on travel time + func adjustedCascadeIntervals( + baseCascade: CascadeConfig, + travelTimeMinutes: Int + ) -> [Int] { + var intervals = getCascadeIntervals(baseCascade) + + // Add travel-time-based warning if not already present + let travelWarning = travelTimeMinutes + 5 // 5 min buffer + if !intervals.contains(travelWarning) { + intervals.append(travelWarning) + intervals.sort(by: >) + } + + // Add a "leave now" warning at exactly travel time + if !intervals.contains(travelTimeMinutes) { + intervals.append(travelTimeMinutes) + intervals.sort(by: >) + } + + return intervals + } + + /// Format a travel advisory message + func travelAdvisory(for estimate: TravelEstimate, timerLabel: String, targetTime: Date) -> String { + let travelMinutes = Int(estimate.travelTimeSeconds / 60) + let distanceKm = estimate.distance / 1000 + + let timeUntilLeave = (estimate.leaveByTime ?? Date()).timeIntervalSinceNow + let leaveMinutes = Int(timeUntilLeave / 60) + + if leaveMinutes <= 0 { + return "Leave now for \(timerLabel) — \(travelMinutes) min \(estimate.transportLabel) (\(String(format: "%.1f", distanceKm)) km)" + } else { + return "\(timerLabel) in \(formatDurationCompact(targetTime.timeIntervalSinceNow)). Leave in \(leaveMinutes) min — \(travelMinutes) min \(estimate.transportLabel)" + } + } + + // MARK: - Cache Management + + func clearCache() { + cachedEstimates.removeAll() + } + + func invalidateEstimate(for timerId: String) { + cachedEstimates.removeValue(forKey: timerId) + } +} + +// MARK: - Travel Estimate Model + +struct TravelEstimate: Codable { + let travelTimeSeconds: TimeInterval + let distance: Double // meters + let transportType: TransportType + let calculatedAt: Date + let departureDate: Date + let arrivalDate: Date + var timerId: String? + var leaveByTime: Date? + + var travelTimeMinutes: Int { + Int(travelTimeSeconds / 60) + } + + var transportLabel: String { + transportType.label + } + + // Wrapper for Codable MKDirectionsTransportType + init( + travelTimeSeconds: TimeInterval, + distance: Double, + transportType: MKDirectionsTransportType, + calculatedAt: Date, + departureDate: Date, + arrivalDate: Date + ) { + self.travelTimeSeconds = travelTimeSeconds + self.distance = distance + self.transportType = TransportType(from: transportType) + self.calculatedAt = calculatedAt + self.departureDate = departureDate + self.arrivalDate = arrivalDate + } +} + +// MARK: - Transport Type (Codable wrapper) + +enum TransportType: String, Codable, CaseIterable, Identifiable { + case automobile = "driving" + case transit = "transit" + case walking = "walking" + + var id: String { rawValue } + + var label: String { + switch self { + case .automobile: return "drive" + case .transit: return "transit" + case .walking: return "walk" + } + } + + var icon: String { + switch self { + case .automobile: return "car.fill" + case .transit: return "bus.fill" + case .walking: return "figure.walk" + } + } + + var mkType: MKDirectionsTransportType { + switch self { + case .automobile: return .automobile + case .transit: return .transit + case .walking: return .walking + } + } + + init(from mkType: MKDirectionsTransportType) { + switch mkType { + case .transit: self = .transit + case .walking: self = .walking + default: self = .automobile + } + } +}