Add EV charging discovery feature (disabled by flag)
- Create EVChargingService using MapKit POI search for EV chargers - Add ItineraryBuilder.enrichWithEVChargers() for post-planning enrichment - Update TravelSection in TripDetailView with expandable charger list - Add FeatureFlags.enableEVCharging toggle (default: false) - Include EVChargingFeature.md documenting API overhead 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -109,6 +109,238 @@ enum ItineraryBuilder {
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - EV Charger Enrichment
|
||||
|
||||
/// Enriches an itinerary option with EV charging stops along each route segment.
|
||||
///
|
||||
/// This is a post-processing step that can be called after the sync planning completes.
|
||||
/// It fetches real routes from MapKit and searches for EV chargers along each segment.
|
||||
///
|
||||
/// - Parameter option: The itinerary option to enrich
|
||||
/// - Returns: A new itinerary option with EV chargers populated in travel segments
|
||||
///
|
||||
static func enrichWithEVChargers(_ option: ItineraryOption) async -> ItineraryOption {
|
||||
var enrichedSegments: [TravelSegment] = []
|
||||
|
||||
for segment in option.travelSegments {
|
||||
// Get coordinates
|
||||
guard let fromCoord = segment.fromLocation.coordinate,
|
||||
let toCoord = segment.toLocation.coordinate else {
|
||||
// No coordinates - keep segment as-is
|
||||
enrichedSegments.append(segment)
|
||||
continue
|
||||
}
|
||||
|
||||
do {
|
||||
// Fetch real route from MapKit
|
||||
let routeInfo = try await LocationService.shared.calculateDrivingRoute(
|
||||
from: fromCoord,
|
||||
to: toCoord
|
||||
)
|
||||
|
||||
// Find EV chargers along the route
|
||||
var evChargers: [EVChargingStop] = []
|
||||
if let polyline = routeInfo.polyline {
|
||||
do {
|
||||
evChargers = try await EVChargingService.shared.findChargersAlongRoute(
|
||||
polyline: polyline,
|
||||
searchRadiusMiles: 5.0,
|
||||
intervalMiles: 100.0
|
||||
)
|
||||
print("[ItineraryBuilder] Found \(evChargers.count) EV chargers: \(segment.fromLocation.name) -> \(segment.toLocation.name)")
|
||||
} catch {
|
||||
print("[ItineraryBuilder] EV charger search failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
// Create enriched segment with real route data and EV chargers
|
||||
let enrichedSegment = TravelSegment(
|
||||
id: segment.id,
|
||||
fromLocation: segment.fromLocation,
|
||||
toLocation: segment.toLocation,
|
||||
travelMode: segment.travelMode,
|
||||
distanceMeters: routeInfo.distance,
|
||||
durationSeconds: routeInfo.expectedTravelTime,
|
||||
scenicScore: segment.scenicScore,
|
||||
evChargingStops: evChargers
|
||||
)
|
||||
enrichedSegments.append(enrichedSegment)
|
||||
|
||||
} catch {
|
||||
print("[ItineraryBuilder] Route calculation failed, keeping original: \(error.localizedDescription)")
|
||||
enrichedSegments.append(segment)
|
||||
}
|
||||
}
|
||||
|
||||
// Recalculate totals with enriched segments
|
||||
let totalDrivingHours = enrichedSegments.reduce(0.0) { $0 + $1.estimatedDrivingHours }
|
||||
let totalDistanceMiles = enrichedSegments.reduce(0.0) { $0 + $1.estimatedDistanceMiles }
|
||||
|
||||
return ItineraryOption(
|
||||
rank: option.rank,
|
||||
stops: option.stops,
|
||||
travelSegments: enrichedSegments,
|
||||
totalDrivingHours: totalDrivingHours,
|
||||
totalDistanceMiles: totalDistanceMiles,
|
||||
geographicRationale: option.geographicRationale
|
||||
)
|
||||
}
|
||||
|
||||
/// Enriches multiple itinerary options with EV chargers.
|
||||
/// Processes in parallel for better performance.
|
||||
static func enrichWithEVChargers(_ options: [ItineraryOption]) async -> [ItineraryOption] {
|
||||
await withTaskGroup(of: (Int, ItineraryOption).self) { group in
|
||||
for (index, option) in options.enumerated() {
|
||||
group.addTask {
|
||||
let enriched = await enrichWithEVChargers(option)
|
||||
return (index, enriched)
|
||||
}
|
||||
}
|
||||
|
||||
var results = [(Int, ItineraryOption)]()
|
||||
for await result in group {
|
||||
results.append(result)
|
||||
}
|
||||
|
||||
// Sort by original index to maintain order
|
||||
return results.sorted { $0.0 < $1.0 }.map { $0.1 }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Async Build with EV Chargers
|
||||
|
||||
/// Builds an itinerary with real route data and optional EV charger discovery.
|
||||
///
|
||||
/// This async version uses MapKit to get actual driving routes (instead of Haversine estimates)
|
||||
/// and can optionally search for EV charging stations along each route segment.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - stops: The stops to connect with travel segments
|
||||
/// - constraints: Driving constraints (drivers, max hours per day)
|
||||
/// - includeEVChargers: Whether to search for EV chargers along routes
|
||||
/// - logPrefix: Prefix for log messages
|
||||
/// - segmentValidator: Optional validation for each segment
|
||||
///
|
||||
/// - Returns: Built itinerary if successful, nil if any segment fails
|
||||
///
|
||||
static func buildAsync(
|
||||
stops: [ItineraryStop],
|
||||
constraints: DrivingConstraints,
|
||||
includeEVChargers: Bool = false,
|
||||
logPrefix: String = "[ItineraryBuilder]",
|
||||
segmentValidator: SegmentValidator? = nil
|
||||
) async -> BuiltItinerary? {
|
||||
|
||||
// Edge case: Single stop or empty = no travel needed
|
||||
if stops.count <= 1 {
|
||||
return BuiltItinerary(
|
||||
stops: stops,
|
||||
travelSegments: [],
|
||||
totalDrivingHours: 0,
|
||||
totalDistanceMiles: 0
|
||||
)
|
||||
}
|
||||
|
||||
var travelSegments: [TravelSegment] = []
|
||||
var totalDrivingHours: Double = 0
|
||||
var totalDistance: Double = 0
|
||||
|
||||
for index in 0..<(stops.count - 1) {
|
||||
let fromStop = stops[index]
|
||||
let toStop = stops[index + 1]
|
||||
|
||||
// Get coordinates
|
||||
guard let fromCoord = fromStop.coordinate,
|
||||
let toCoord = toStop.coordinate else {
|
||||
print("\(logPrefix) Missing coordinates: \(fromStop.city) -> \(toStop.city)")
|
||||
// Fall back to estimate
|
||||
guard let segment = TravelEstimator.estimate(
|
||||
from: fromStop,
|
||||
to: toStop,
|
||||
constraints: constraints
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
travelSegments.append(segment)
|
||||
totalDrivingHours += segment.estimatedDrivingHours
|
||||
totalDistance += segment.estimatedDistanceMiles
|
||||
continue
|
||||
}
|
||||
|
||||
// Fetch real route from MapKit
|
||||
do {
|
||||
let routeInfo = try await LocationService.shared.calculateDrivingRoute(
|
||||
from: fromCoord,
|
||||
to: toCoord
|
||||
)
|
||||
|
||||
// Find EV chargers if requested and polyline is available
|
||||
var evChargers: [EVChargingStop] = []
|
||||
if includeEVChargers, let polyline = routeInfo.polyline {
|
||||
do {
|
||||
evChargers = try await EVChargingService.shared.findChargersAlongRoute(
|
||||
polyline: polyline,
|
||||
searchRadiusMiles: 5.0,
|
||||
intervalMiles: 100.0
|
||||
)
|
||||
print("\(logPrefix) Found \(evChargers.count) EV chargers: \(fromStop.city) -> \(toStop.city)")
|
||||
} catch {
|
||||
print("\(logPrefix) EV charger search failed: \(error.localizedDescription)")
|
||||
// Continue without chargers - not a critical failure
|
||||
}
|
||||
}
|
||||
|
||||
let segment = TravelSegment(
|
||||
fromLocation: fromStop.location,
|
||||
toLocation: toStop.location,
|
||||
travelMode: .drive,
|
||||
distanceMeters: routeInfo.distance,
|
||||
durationSeconds: routeInfo.expectedTravelTime,
|
||||
evChargingStops: evChargers
|
||||
)
|
||||
|
||||
// Run optional validator
|
||||
if let validator = segmentValidator {
|
||||
if !validator(segment, fromStop, toStop) {
|
||||
print("\(logPrefix) Segment validation failed: \(fromStop.city) -> \(toStop.city)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
travelSegments.append(segment)
|
||||
totalDrivingHours += segment.estimatedDrivingHours
|
||||
totalDistance += segment.estimatedDistanceMiles
|
||||
|
||||
} catch {
|
||||
print("\(logPrefix) Route calculation failed, using estimate: \(error.localizedDescription)")
|
||||
// Fall back to estimate
|
||||
guard let segment = TravelEstimator.estimate(
|
||||
from: fromStop,
|
||||
to: toStop,
|
||||
constraints: constraints
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
travelSegments.append(segment)
|
||||
totalDrivingHours += segment.estimatedDrivingHours
|
||||
totalDistance += segment.estimatedDistanceMiles
|
||||
}
|
||||
}
|
||||
|
||||
// Verify invariant
|
||||
guard travelSegments.count == stops.count - 1 else {
|
||||
print("\(logPrefix) Invariant violated: \(travelSegments.count) segments for \(stops.count) stops")
|
||||
return nil
|
||||
}
|
||||
|
||||
return BuiltItinerary(
|
||||
stops: stops,
|
||||
travelSegments: travelSegments,
|
||||
totalDrivingHours: totalDrivingHours,
|
||||
totalDistanceMiles: totalDistance
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Common Validators
|
||||
|
||||
/// Validator that ensures travel duration allows arrival before game start.
|
||||
|
||||
Reference in New Issue
Block a user