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:
Trey t
2026-01-08 11:31:04 -06:00
parent 8bf8bb49cb
commit bac9cad20b
7 changed files with 740 additions and 32 deletions

View File

@@ -313,11 +313,19 @@ final class TripCreationViewModel {
let result = planningEngine.planItineraries(request: request)
switch result {
case .success(let options):
case .success(var options):
guard !options.isEmpty else {
viewState = .error("No valid itinerary found")
return
}
// Enrich with EV chargers if requested and feature is enabled
if FeatureFlags.enableEVCharging && needsEVCharging {
print("[TripCreation] Enriching \(options.count) options with EV chargers...")
options = await ItineraryBuilder.enrichWithEVChargers(options)
print("[TripCreation] EV charger enrichment complete")
}
// Store preferences for later conversion
currentPreferences = preferences

View File

@@ -535,12 +535,14 @@ struct TripCreationView: View {
Divider()
.overlay(Theme.surfaceGlow(colorScheme))
// EV Charging
ThemedToggle(
label: "EV Charging Needed",
isOn: $viewModel.needsEVCharging,
icon: "bolt.car"
)
// EV Charging (feature flagged)
if FeatureFlags.enableEVCharging {
ThemedToggle(
label: "EV Charging Needed",
isOn: $viewModel.needsEVCharging,
icon: "bolt.car"
)
}
// Drivers
ThemedStepper(

View File

@@ -623,6 +623,11 @@ struct GameRow: View {
struct TravelSection: View {
let segment: TravelSegment
@Environment(\.colorScheme) private var colorScheme
@State private var showEVChargers = false
private var hasEVChargers: Bool {
!segment.evChargingStops.isEmpty
}
var body: some View {
VStack(spacing: 0) {
@@ -632,39 +637,85 @@ struct TravelSection: View {
.frame(width: 2, height: 16)
// Travel card
HStack(spacing: Theme.Spacing.md) {
// Icon
ZStack {
Circle()
.fill(Theme.cardBackgroundElevated(colorScheme))
.frame(width: 44, height: 44)
VStack(spacing: 0) {
// Main travel info
HStack(spacing: Theme.Spacing.md) {
// Icon
ZStack {
Circle()
.fill(Theme.cardBackgroundElevated(colorScheme))
.frame(width: 44, height: 44)
Image(systemName: "car.fill")
.foregroundStyle(Theme.routeGold)
Image(systemName: "car.fill")
.foregroundStyle(Theme.routeGold)
}
VStack(alignment: .leading, spacing: 2) {
Text("Travel")
.font(.system(size: Theme.FontSize.micro, weight: .semibold))
.foregroundStyle(Theme.textMuted(colorScheme))
Text("\(segment.fromLocation.name)\(segment.toLocation.name)")
.font(.system(size: Theme.FontSize.body, weight: .medium))
.foregroundStyle(Theme.textPrimary(colorScheme))
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text(segment.formattedDistance)
.font(.system(size: Theme.FontSize.caption, weight: .semibold))
.foregroundStyle(Theme.textPrimary(colorScheme))
Text(segment.formattedDuration)
.font(.system(size: Theme.FontSize.micro))
.foregroundStyle(Theme.textSecondary(colorScheme))
}
}
.padding(Theme.Spacing.md)
VStack(alignment: .leading, spacing: 2) {
Text("Travel")
.font(.system(size: Theme.FontSize.micro, weight: .semibold))
.foregroundStyle(Theme.textMuted(colorScheme))
// EV Chargers section (if available)
if hasEVChargers {
Divider()
.background(Theme.routeGold.opacity(0.2))
Text("\(segment.fromLocation.name)\(segment.toLocation.name)")
.font(.system(size: Theme.FontSize.body, weight: .medium))
.foregroundStyle(Theme.textPrimary(colorScheme))
}
Button {
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
showEVChargers.toggle()
}
} label: {
HStack(spacing: Theme.Spacing.sm) {
Image(systemName: "bolt.fill")
.foregroundStyle(.green)
.font(.system(size: 12))
Spacer()
Text("\(segment.evChargingStops.count) EV Charger\(segment.evChargingStops.count > 1 ? "s" : "") Along Route")
.font(.system(size: Theme.FontSize.caption, weight: .medium))
.foregroundStyle(Theme.textPrimary(colorScheme))
VStack(alignment: .trailing, spacing: 2) {
Text(segment.formattedDistance)
.font(.system(size: Theme.FontSize.caption, weight: .semibold))
.foregroundStyle(Theme.textPrimary(colorScheme))
Text(segment.formattedDuration)
.font(.system(size: Theme.FontSize.micro))
.foregroundStyle(Theme.textSecondary(colorScheme))
Spacer()
Image(systemName: showEVChargers ? "chevron.up" : "chevron.down")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(Theme.textMuted(colorScheme))
}
.padding(.horizontal, Theme.Spacing.md)
.padding(.vertical, Theme.Spacing.sm)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
if showEVChargers {
VStack(spacing: 0) {
ForEach(segment.evChargingStops) { charger in
EVChargerRow(charger: charger)
}
}
.padding(.horizontal, Theme.Spacing.md)
.padding(.bottom, Theme.Spacing.sm)
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
}
.padding(Theme.Spacing.md)
.background(Theme.cardBackground(colorScheme).opacity(0.7))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
.overlay {
@@ -680,6 +731,82 @@ struct TravelSection: View {
}
}
// MARK: - EV Charger Row
struct EVChargerRow: View {
let charger: EVChargingStop
@Environment(\.colorScheme) private var colorScheme
var body: some View {
HStack(spacing: Theme.Spacing.sm) {
// Connector line indicator
VStack(spacing: 0) {
Rectangle()
.fill(.green.opacity(0.3))
.frame(width: 1, height: 8)
Circle()
.fill(.green)
.frame(width: 6, height: 6)
Rectangle()
.fill(.green.opacity(0.3))
.frame(width: 1, height: 8)
}
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: Theme.Spacing.xs) {
Text(charger.name)
.font(.system(size: Theme.FontSize.caption, weight: .medium))
.foregroundStyle(Theme.textPrimary(colorScheme))
.lineLimit(1)
chargerTypeBadge
}
HStack(spacing: Theme.Spacing.xs) {
if let address = charger.location.address {
Text(address)
.font(.system(size: Theme.FontSize.micro))
.foregroundStyle(Theme.textSecondary(colorScheme))
}
Text("")
.foregroundStyle(Theme.textMuted(colorScheme))
Text("~\(charger.formattedChargeTime) charge")
.font(.system(size: Theme.FontSize.micro))
.foregroundStyle(Theme.textSecondary(colorScheme))
}
}
Spacer()
}
.padding(.vertical, 6)
}
@ViewBuilder
private var chargerTypeBadge: some View {
let (text, color) = chargerTypeInfo
Text(text)
.font(.system(size: 9, weight: .semibold))
.foregroundStyle(color)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(color.opacity(0.15))
.clipShape(Capsule())
}
private var chargerTypeInfo: (String, Color) {
switch charger.chargerType {
case .supercharger:
return ("Supercharger", .red)
case .dcFast:
return ("DC Fast", .blue)
case .level2:
return ("Level 2", .green)
}
}
}
// MARK: - Share Sheet
struct ShareSheet: UIViewControllerRepresentable {