feat(location): add CoreLocation geofencing triggers + MapKit travel time intelligence
This commit is contained in:
parent
351410ba41
commit
4341502e33
285
ios/ChronoMind/Shared/Location/LocationTriggerManager.swift
Normal file
285
ios/ChronoMind/Shared/Location/LocationTriggerManager.swift
Normal 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")
|
||||
}
|
||||
205
ios/ChronoMind/Shared/Location/TravelTimeManager.swift
Normal file
205
ios/ChronoMind/Shared/Location/TravelTimeManager.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user