learning_ai_clock/ios/ChronoMind/Shared/Location/LocationTriggerManager.swift

286 lines
9.1 KiB
Swift

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