// ── 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 } } }