feat(location): add CoreLocation geofencing triggers + MapKit travel time intelligence

This commit is contained in:
saravanakumardb1 2026-02-27 22:35:48 -08:00
parent 351410ba41
commit 4341502e33
2 changed files with 490 additions and 0 deletions

View File

@ -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")
}

View File

@ -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
}
}
}