Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions Shared/Intents/ShuttleTrackerShortcuts.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
192 changes: 192 additions & 0 deletions Shared/Managers/GeofenceManager.swift
Original file line number Diff line number Diff line change
@@ -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<String> = []

/// Whether the user has granted Always authorization
@Published private(set) var hasAlwaysAuthorization = false

private var cancellables = Set<AnyCancellable>()

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)")
}
}
112 changes: 112 additions & 0 deletions Shared/Managers/NotificationManager.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
33 changes: 33 additions & 0 deletions Shared/Managers/SettingsManager.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading