286 lines
9.1 KiB
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")
|
|
}
|