206 lines
6.4 KiB
Swift
206 lines
6.4 KiB
Swift
// ── 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
|
|
}
|
|
}
|
|
}
|