From 3e984edf7101cf9ed2295ec47b4662cc236e120c Mon Sep 17 00:00:00 2001 From: Richard Sun Date: Sat, 13 Dec 2025 10:30:31 -0500 Subject: [PATCH] feat: add geofence-based notifications for shuttle stops - Add GeofenceManager for monitoring 50m radius around stops - Add NotificationManager for local notification handling - Add SettingsManager with stopNotificationsEnabled toggle - Add ShuttleTrackerShortcuts for Siri integration - Add Info.plist with location usage descriptions - Update LocationManager with alwaysAuthorization method - Update ShuttleTrackerApp to inject environment objects Uses iOS's battery-efficient region monitoring API. Notifications auto-dismiss when leaving stop area. --- Shared/Intents/ShuttleTrackerShortcuts.swift | 51 +++++ Shared/Managers/GeofenceManager.swift | 192 +++++++++++++++++++ Shared/Managers/NotificationManager.swift | 112 +++++++++++ Shared/Managers/SettingsManager.swift | 33 ++++ Shared/ShuttleTrackerApp.swift | 10 + Shared/Views/LocationManager.swift | 4 + iOS/Info.plist | 18 ++ 7 files changed, 420 insertions(+) create mode 100644 Shared/Intents/ShuttleTrackerShortcuts.swift create mode 100644 Shared/Managers/GeofenceManager.swift create mode 100644 Shared/Managers/NotificationManager.swift create mode 100644 Shared/Managers/SettingsManager.swift create mode 100644 iOS/Info.plist diff --git a/Shared/Intents/ShuttleTrackerShortcuts.swift b/Shared/Intents/ShuttleTrackerShortcuts.swift new file mode 100644 index 0000000..bfb1b1a --- /dev/null +++ b/Shared/Intents/ShuttleTrackerShortcuts.swift @@ -0,0 +1,51 @@ +// +// ShuttleTrackerShortcuts.swift +// Shuttle Tracker +// +// Created by Claude on 12/13/25. +// + +import Foundation +import Intents + +/// Handles Siri Shortcuts integration for the Shuttle Tracker app +struct ShuttleTrackerShortcuts { + + /// Activity type for opening the app + static let openAppActivityType = "edu.rpi.shuttletracker.openApp" + + /// Donate an activity to Siri when the user opens the app near a stop + /// This teaches Siri the user's pattern and enables proactive suggestions + static func donateOpenAppActivity(nearStop stopName: String? = nil) { + let activity = NSUserActivity(activityType: openAppActivityType) + activity.title = "Open Shuttle Tracker" + activity.isEligibleForSearch = true + activity.isEligibleForPrediction = true + + // Set suggested invocation phrase + if let stopName = stopName { + activity.suggestedInvocationPhrase = "Check shuttles near \(stopName)" + activity.userInfo = ["stopName": stopName] + } else { + activity.suggestedInvocationPhrase = "Check shuttle times" + } + + // Keywords for Spotlight search + activity.keywords = Set(["shuttle", "bus", "RPI", "Rensselaer", "tracker", "transit"]) + + // Make it current to donate to Siri + activity.becomeCurrent() + } + + /// Handle an incoming user activity (from Siri invocation) + static func handleIncomingActivity(_ activity: NSUserActivity) -> Bool { + guard activity.activityType == openAppActivityType else { return false } + + // The app is opening - we could navigate to a specific view if needed + if let stopName = activity.userInfo?["stopName"] as? String { + print("Opened via Siri shortcut near stop: \(stopName)") + } + + return true + } +} diff --git a/Shared/Managers/GeofenceManager.swift b/Shared/Managers/GeofenceManager.swift new file mode 100644 index 0000000..c612ebb --- /dev/null +++ b/Shared/Managers/GeofenceManager.swift @@ -0,0 +1,192 @@ +// +// GeofenceManager.swift +// Shuttle Tracker +// +// Created by Claude on 12/13/25. +// + +import Foundation +import CoreLocation +import Combine + +/// Manages geofencing for shuttle stop proximity detection +class GeofenceManager: NSObject, ObservableObject { + static let shared = GeofenceManager() + + private let locationManager = CLLocationManager() + private let settingsManager = SettingsManager.shared + private let notificationManager = NotificationManager.shared + + /// Geofence radius in meters around each stop + private let geofenceRadius: CLLocationDistance = 50.0 + + /// Currently registered stop regions + @Published private(set) var registeredStops: [String: CLCircularRegion] = [:] + + /// Currently inside these regions + @Published private(set) var currentlyInsideStops: Set = [] + + /// Whether the user has granted Always authorization + @Published private(set) var hasAlwaysAuthorization = false + + private var cancellables = Set() + + override init() { + super.init() + locationManager.delegate = self + // Note: Region monitoring uses iOS's low-power coprocessor and doesn't + // require continuous GPS updates, making it battery-efficient + + checkAuthorizationStatus() + + // Observe settings changes to start/stop geofencing + settingsManager.$stopNotificationsEnabled + .sink { [weak self] enabled in + if enabled { + self?.startMonitoringIfAuthorized() + } else { + self?.stopMonitoringAllStops() + self?.notificationManager.dismissAllStopNotifications() + } + } + .store(in: &cancellables) + } + + /// Request "Always" location authorization for background geofencing + func requestAlwaysAuthorization() { + locationManager.requestAlwaysAuthorization() + } + + /// Fetch stops from API and register geofences + func setupGeofencesFromAPI() { + Task { + do { + let routes = try await API.shared.fetch(ShuttleRouteData.self, endpoint: "routes") + await MainActor.run { + registerGeofences(from: routes) + } + } catch { + print("Error fetching routes for geofencing: \(error)") + } + } + } + + /// Register geofences for all unique stops from route data + private func registerGeofences(from routes: ShuttleRouteData) { + guard settingsManager.stopNotificationsEnabled else { return } + guard hasAlwaysAuthorization else { + print("Cannot register geofences: Always authorization not granted") + return + } + + // Clear existing regions first + stopMonitoringAllStops() + + // Collect unique stops from all routes + var uniqueStops: [String: (latitude: Double, longitude: Double)] = [:] + + for (_, routeData) in routes { + for (stopName, stopData) in routeData.stopDetails { + guard stopData.coordinates.count == 2 else { continue } + uniqueStops[stopName] = (latitude: stopData.coordinates[0], longitude: stopData.coordinates[1]) + } + } + + // Register a geofence for each stop + for (stopName, coords) in uniqueStops { + let center = CLLocationCoordinate2D(latitude: coords.latitude, longitude: coords.longitude) + let identifier = "stop_\(stopName)" + + let region = CLCircularRegion( + center: center, + radius: geofenceRadius, + identifier: identifier + ) + region.notifyOnEntry = true + region.notifyOnExit = true + + locationManager.startMonitoring(for: region) + registeredStops[identifier] = region + } + + print("Registered \(registeredStops.count) stop geofences") + } + + /// Stop monitoring all registered stop regions + func stopMonitoringAllStops() { + for (_, region) in registeredStops { + locationManager.stopMonitoring(for: region) + } + registeredStops.removeAll() + currentlyInsideStops.removeAll() + } + + /// Check current authorization and update state + private func checkAuthorizationStatus() { + let status = locationManager.authorizationStatus + hasAlwaysAuthorization = (status == .authorizedAlways) + } + + /// Start monitoring if we have authorization + private func startMonitoringIfAuthorized() { + if hasAlwaysAuthorization && registeredStops.isEmpty { + setupGeofencesFromAPI() + } + } + + /// Extract stop name from region identifier + private func stopName(from identifier: String) -> String { + if identifier.hasPrefix("stop_") { + return String(identifier.dropFirst(5)) + } + return identifier + } +} + +// MARK: - CLLocationManagerDelegate + +extension GeofenceManager: CLLocationManagerDelegate { + + func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + checkAuthorizationStatus() + + if hasAlwaysAuthorization { + startMonitoringIfAuthorized() + + // Also request notification permission now that we have location + notificationManager.requestAuthorization() + } + } + + func locationManager(_ manager: CLLocationManager, didEnterRegion region: CLRegion) { + guard let circularRegion = region as? CLCircularRegion else { return } + guard settingsManager.stopNotificationsEnabled else { return } + + let identifier = circularRegion.identifier + currentlyInsideStops.insert(identifier) + + let name = stopName(from: identifier) + notificationManager.showStopProximityNotification(stopName: name, regionIdentifier: identifier) + + // Donate to Siri for suggestions + ShuttleTrackerShortcuts.donateOpenAppActivity(nearStop: name) + + print("Entered stop region: \(name)") + } + + func locationManager(_ manager: CLLocationManager, didExitRegion region: CLRegion) { + guard let circularRegion = region as? CLCircularRegion else { return } + + let identifier = circularRegion.identifier + currentlyInsideStops.remove(identifier) + + // Dismiss the notification when leaving the stop + notificationManager.dismissStopProximityNotification(regionIdentifier: identifier) + + print("Exited stop region: \(stopName(from: identifier))") + } + + func locationManager(_ manager: CLLocationManager, monitoringDidFailFor region: CLRegion?, withError error: Error) { + print("Geofence monitoring failed for \(region?.identifier ?? "unknown"): \(error.localizedDescription)") + } +} diff --git a/Shared/Managers/NotificationManager.swift b/Shared/Managers/NotificationManager.swift new file mode 100644 index 0000000..aebea9f --- /dev/null +++ b/Shared/Managers/NotificationManager.swift @@ -0,0 +1,112 @@ +// +// NotificationManager.swift +// Shuttle Tracker +// +// Created by Claude on 12/13/25. +// + +import Foundation +import UserNotifications +import Combine + +/// Manages local notifications for shuttle stop proximity alerts +class NotificationManager: NSObject, ObservableObject { + static let shared = NotificationManager() + + @Published var isAuthorized = false + + /// Category identifier for stop proximity notifications + static let stopProximityCategoryIdentifier = "STOP_PROXIMITY" + + /// Action identifier for opening the app + static let openAppActionIdentifier = "OPEN_APP" + + override init() { + super.init() + checkAuthorizationStatus() + configureNotificationCategories() + } + + /// Request notification authorization from the user + func requestAuthorization() { + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { [weak self] granted, error in + DispatchQueue.main.async { + self?.isAuthorized = granted + } + + if let error = error { + print("Notification authorization error: \(error.localizedDescription)") + } + } + } + + /// Check current authorization status + private func checkAuthorizationStatus() { + UNUserNotificationCenter.current().getNotificationSettings { [weak self] settings in + DispatchQueue.main.async { + self?.isAuthorized = settings.authorizationStatus == .authorized + } + } + } + + /// Configure notification categories and actions + private func configureNotificationCategories() { + let openAction = UNNotificationAction( + identifier: Self.openAppActionIdentifier, + title: "View Stop", + options: .foreground + ) + + let category = UNNotificationCategory( + identifier: Self.stopProximityCategoryIdentifier, + actions: [openAction], + intentIdentifiers: [], + options: [] + ) + + UNUserNotificationCenter.current().setNotificationCategories([category]) + } + + /// Show a notification for entering a shuttle stop region + /// - Parameters: + /// - stopName: The name of the shuttle stop + /// - regionIdentifier: Unique identifier for the region (used to dismiss later) + func showStopProximityNotification(stopName: String, regionIdentifier: String) { + let content = UNMutableNotificationContent() + content.title = "Shuttle Stop Nearby" + content.body = "You're near \(stopName). Open Shuttle Tracker?" + content.sound = .default + content.categoryIdentifier = Self.stopProximityCategoryIdentifier + + // Use the region identifier as the notification identifier for easy dismissal + let request = UNNotificationRequest( + identifier: regionIdentifier, + content: content, + trigger: nil // Deliver immediately + ) + + UNUserNotificationCenter.current().add(request) { error in + if let error = error { + print("Error showing notification: \(error.localizedDescription)") + } + } + } + + /// Dismiss a notification for leaving a shuttle stop region + /// - Parameter regionIdentifier: The same identifier used when showing the notification + func dismissStopProximityNotification(regionIdentifier: String) { + UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [regionIdentifier]) + UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [regionIdentifier]) + } + + /// Dismiss all stop proximity notifications + func dismissAllStopNotifications() { + UNUserNotificationCenter.current().getDeliveredNotifications { notifications in + let stopNotificationIds = notifications + .filter { $0.request.content.categoryIdentifier == Self.stopProximityCategoryIdentifier } + .map { $0.request.identifier } + + UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: stopNotificationIds) + } + } +} diff --git a/Shared/Managers/SettingsManager.swift b/Shared/Managers/SettingsManager.swift new file mode 100644 index 0000000..578cf7b --- /dev/null +++ b/Shared/Managers/SettingsManager.swift @@ -0,0 +1,33 @@ +// +// SettingsManager.swift +// Shuttle Tracker +// +// Created by Claude on 12/13/25. +// + +import Foundation +import Combine + +/// Manages user preferences using UserDefaults with @AppStorage-compatible keys +class SettingsManager: ObservableObject { + static let shared = SettingsManager() + + private let defaults = UserDefaults.standard + + /// Key constants for UserDefaults + private enum Keys { + static let stopNotificationsEnabled = "stopNotificationsEnabled" + } + + /// Whether geofence notifications for shuttle stops are enabled + @Published var stopNotificationsEnabled: Bool { + didSet { + defaults.set(stopNotificationsEnabled, forKey: Keys.stopNotificationsEnabled) + } + } + + private init() { + // Default to true for new users, load from UserDefaults for returning users + self.stopNotificationsEnabled = defaults.object(forKey: Keys.stopNotificationsEnabled) as? Bool ?? true + } +} diff --git a/Shared/ShuttleTrackerApp.swift b/Shared/ShuttleTrackerApp.swift index 8ec58a0..84d7af2 100644 --- a/Shared/ShuttleTrackerApp.swift +++ b/Shared/ShuttleTrackerApp.swift @@ -10,9 +10,19 @@ import MapKit @main struct ShuttleTrackerApp: App { + @StateObject private var settingsManager = SettingsManager.shared + @StateObject private var geofenceManager = GeofenceManager.shared + @StateObject private var notificationManager = NotificationManager.shared + var body: some Scene { WindowGroup { MapView() + .environmentObject(settingsManager) + .environmentObject(geofenceManager) + .environmentObject(notificationManager) + .onContinueUserActivity(ShuttleTrackerShortcuts.openAppActivityType) { activity in + _ = ShuttleTrackerShortcuts.handleIncomingActivity(activity) + } } } } diff --git a/Shared/Views/LocationManager.swift b/Shared/Views/LocationManager.swift index 8b9f197..4afc18f 100644 --- a/Shared/Views/LocationManager.swift +++ b/Shared/Views/LocationManager.swift @@ -14,6 +14,10 @@ class LocationManager: NSObject, ObservableObject, CLLocationManagerDelegate { locationManager.requestWhenInUseAuthorization() } + func requestAlwaysAuthorization() { + locationManager.requestAlwaysAuthorization() + } + func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { switch manager.authorizationStatus { case .authorizedAlways, .authorizedWhenInUse: diff --git a/iOS/Info.plist b/iOS/Info.plist new file mode 100644 index 0000000..936640d --- /dev/null +++ b/iOS/Info.plist @@ -0,0 +1,18 @@ + + + + + NSLocationWhenInUseUsageDescription + We use your location to show you on the map and calculate shuttle ETAs for nearby stops. + NSLocationAlwaysAndWhenInUseUsageDescription + Allow always access to receive notifications when you're near a shuttle stop, even when the app is closed. + UIBackgroundModes + + location + + NSUserActivityTypes + + edu.rpi.shuttletracker.openApp + + +