Files
Sportstime/SportsTime/Features/Trip/Views/TripCreationView.swift
Trey t ab89c25f2f Refactor trip planning: DAG router + trip options UI + simplified itinerary
- Replace O(2^n) GeographicRouteExplorer with O(n) GameDAGRouter using DAG + beam search
- Add geographic diversity to route selection (returns routes from distinct regions)
- Add trip options selector UI (TripOptionsView, TripOptionCard) to choose between routes
- Simplify itinerary display: separate games and travel segments by date
- Remove complex ItineraryDay bundling, query games/travel directly per day
- Update ScenarioA/B/C planners to use GameDAGRouter
- Add new test suites for planners and travel estimator

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 12:26:17 -06:00

1080 lines
36 KiB
Swift

//
// TripCreationView.swift
// SportsTime
//
import SwiftUI
struct TripCreationView: View {
@State private var viewModel = TripCreationViewModel()
@State private var showGamePicker = false
@State private var showCityInput = false
@State private var cityInputType: CityInputType = .mustStop
@State private var showLocationBanner = true
@State private var showTripDetail = false
@State private var showTripOptions = false
@State private var completedTrip: Trip?
@State private var tripOptions: [ItineraryOption] = []
enum CityInputType {
case mustStop
case preferred
}
var body: some View {
NavigationStack {
Form {
// Planning Mode Selector
planningModeSection
// Location Permission Banner (only for locations mode)
if viewModel.planningMode == .locations && showLocationBanner {
Section {
LocationPermissionBanner(isPresented: $showLocationBanner)
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
}
}
// Mode-specific sections
switch viewModel.planningMode {
case .dateRange:
// Sports + Dates
sportsSection
datesSection
case .gameFirst:
// Sports + Game Picker
sportsSection
gameBrowserSection
tripBufferSection
case .locations:
// Locations + Sports + optional games
locationSection
sportsSection
datesSection
gamesSection
}
// Common sections
travelSection
constraintsSection
optionalSection
// Validation message
if let message = viewModel.formValidationMessage {
Section {
Label(message, systemImage: "exclamationmark.triangle")
.foregroundStyle(.orange)
}
}
}
.navigationTitle("Plan Your Trip")
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Plan") {
Task {
await viewModel.planTrip()
}
}
.disabled(!viewModel.isFormValid)
}
}
.overlay {
if case .planning = viewModel.viewState {
planningOverlay
}
}
.sheet(isPresented: $showGamePicker) {
GamePickerSheet(
games: viewModel.availableGames,
selectedIds: $viewModel.mustSeeGameIds
)
}
.sheet(isPresented: $showCityInput) {
LocationSearchSheet(inputType: cityInputType) { location in
switch cityInputType {
case .mustStop:
viewModel.addMustStopLocation(location)
case .preferred:
viewModel.addPreferredCity(location.name)
}
}
}
.alert("Error", isPresented: Binding(
get: { viewModel.viewState.isError },
set: { if !$0 { viewModel.viewState = .editing } }
)) {
Button("OK") {
viewModel.viewState = .editing
}
} message: {
if case .error(let message) = viewModel.viewState {
Text(message)
}
}
.navigationDestination(isPresented: $showTripOptions) {
TripOptionsView(
options: tripOptions,
games: buildGamesDictionary(),
preferences: viewModel.currentPreferences,
convertToTrip: { option in
viewModel.convertOptionToTrip(option)
}
)
}
.navigationDestination(isPresented: $showTripDetail) {
if let trip = completedTrip {
TripDetailView(trip: trip, games: buildGamesDictionary())
}
}
.onChange(of: viewModel.viewState) { _, newState in
switch newState {
case .selectingOption(let options):
tripOptions = options
showTripOptions = true
case .completed(let trip):
completedTrip = trip
showTripDetail = true
default:
break
}
}
.onChange(of: showTripOptions) { _, isShowing in
if !isShowing {
// User navigated back from options to editing
viewModel.viewState = .editing
tripOptions = []
}
}
.onChange(of: showTripDetail) { _, isShowing in
if !isShowing {
// User navigated back from single-option detail to editing
completedTrip = nil
viewModel.viewState = .editing
}
}
.task {
await viewModel.loadScheduleData()
}
}
}
// MARK: - Sections
private var planningModeSection: some View {
Section {
Picker("Planning Mode", selection: $viewModel.planningMode) {
ForEach(PlanningMode.allCases) { mode in
Label(mode.displayName, systemImage: mode.iconName)
.tag(mode)
}
}
.pickerStyle(.segmented)
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
Text(viewModel.planningMode.description)
.font(.caption)
.foregroundStyle(.secondary)
}
}
private var locationSection: some View {
Section("Locations") {
TextField("Start Location", text: $viewModel.startLocationText)
.textContentType(.addressCity)
TextField("End Location", text: $viewModel.endLocationText)
.textContentType(.addressCity)
}
}
private var gameBrowserSection: some View {
Section("Select Games") {
if viewModel.isLoadingGames {
HStack {
ProgressView()
Text("Loading games...")
.foregroundStyle(.secondary)
}
} else if viewModel.availableGames.isEmpty {
HStack {
ProgressView()
Text("Loading games...")
.foregroundStyle(.secondary)
}
.task {
await viewModel.loadGamesForBrowsing()
}
} else {
Button {
showGamePicker = true
} label: {
HStack {
Image(systemName: "sportscourt")
.foregroundStyle(.blue)
VStack(alignment: .leading, spacing: 2) {
Text("Browse Teams & Games")
.foregroundStyle(.primary)
Text("\(viewModel.availableGames.count) games available")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.foregroundStyle(.secondary)
}
}
.buttonStyle(.plain)
}
// Show selected games summary
if !viewModel.mustSeeGameIds.isEmpty {
VStack(alignment: .leading, spacing: 8) {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
Text("\(viewModel.mustSeeGameIds.count) game(s) selected")
.fontWeight(.medium)
}
// Show selected games preview
ForEach(viewModel.selectedGames.prefix(3)) { game in
HStack(spacing: 8) {
Image(systemName: game.game.sport.iconName)
.font(.caption)
.foregroundStyle(.secondary)
Text("\(game.awayTeam.abbreviation) @ \(game.homeTeam.abbreviation)")
.font(.caption)
Spacer()
Text(game.game.formattedDate)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
if viewModel.selectedGames.count > 3 {
Text("+ \(viewModel.selectedGames.count - 3) more")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
}
private var tripBufferSection: some View {
Section("Trip Duration") {
Stepper("Buffer Days: \(viewModel.tripBufferDays)", value: $viewModel.tripBufferDays, in: 0...7)
if let dateRange = viewModel.gameFirstDateRange {
HStack {
Text("Trip window:")
Spacer()
Text("\(dateRange.start.formatted(date: .abbreviated, time: .omitted)) - \(dateRange.end.formatted(date: .abbreviated, time: .omitted))")
.foregroundStyle(.secondary)
}
}
Text("Days before first game and after last game for travel/rest")
.font(.caption)
.foregroundStyle(.secondary)
}
}
private var sportsSection: some View {
Section("Sports") {
ForEach(Sport.supported) { sport in
Toggle(isOn: binding(for: sport)) {
Label(sport.rawValue, systemImage: sport.iconName)
}
}
}
}
private var datesSection: some View {
Section("Dates") {
DatePicker("Start Date", selection: $viewModel.startDate, displayedComponents: .date)
DatePicker("End Date", selection: $viewModel.endDate, displayedComponents: .date)
Text("\(viewModel.tripDurationDays) day trip")
.foregroundStyle(.secondary)
}
}
private var gamesSection: some View {
Section("Must-See Games") {
Button {
showGamePicker = true
} label: {
HStack {
Text("Select Games")
Spacer()
Text("\(viewModel.selectedGamesCount) selected")
.foregroundStyle(.secondary)
}
}
}
}
private var travelSection: some View {
Section("Travel") {
Picker("Travel Mode", selection: $viewModel.travelMode) {
ForEach(TravelMode.allCases) { mode in
Label(mode.displayName, systemImage: mode.iconName)
.tag(mode)
}
}
Picker("Route Preference", selection: $viewModel.routePreference) {
ForEach(RoutePreference.allCases) { pref in
Text(pref.displayName).tag(pref)
}
}
}
}
private var constraintsSection: some View {
Section("Trip Style") {
Toggle("Use Stop Count", isOn: $viewModel.useStopCount)
if viewModel.useStopCount {
Stepper("Number of Stops: \(viewModel.numberOfStops)", value: $viewModel.numberOfStops, in: 1...20)
}
Picker("Pace", selection: $viewModel.leisureLevel) {
ForEach(LeisureLevel.allCases) { level in
VStack(alignment: .leading) {
Text(level.displayName)
}
.tag(level)
}
}
Text(viewModel.leisureLevel.description)
.font(.caption)
.foregroundStyle(.secondary)
}
}
private var optionalSection: some View {
Section("Optional") {
// Must-Stop Locations
DisclosureGroup("Must-Stop Locations (\(viewModel.mustStopLocations.count))") {
ForEach(viewModel.mustStopLocations, id: \.name) { location in
HStack {
VStack(alignment: .leading) {
Text(location.name)
if let address = location.address, !address.isEmpty {
Text(address)
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
Button(role: .destructive) {
viewModel.removeMustStopLocation(location)
} label: {
Image(systemName: "minus.circle.fill")
}
}
}
Button("Add Location") {
cityInputType = .mustStop
showCityInput = true
}
}
// EV Charging
if viewModel.travelMode == .drive {
Toggle("EV Charging Needed", isOn: $viewModel.needsEVCharging)
}
// Lodging
Picker("Lodging Type", selection: $viewModel.lodgingType) {
ForEach(LodgingType.allCases) { type in
Label(type.displayName, systemImage: type.iconName)
.tag(type)
}
}
// Drivers
if viewModel.travelMode == .drive {
Stepper("Drivers: \(viewModel.numberOfDrivers)", value: $viewModel.numberOfDrivers, in: 1...4)
HStack {
Text("Max Hours/Driver/Day")
Spacer()
Text("\(Int(viewModel.maxDrivingHoursPerDriver))h")
}
Slider(value: $viewModel.maxDrivingHoursPerDriver, in: 4...12, step: 1)
}
// Other Sports
Toggle("Find Other Sports Along Route", isOn: $viewModel.catchOtherSports)
}
}
private var planningOverlay: some View {
ZStack {
Color.black.opacity(0.4)
.ignoresSafeArea()
VStack(spacing: 20) {
ProgressView()
.scaleEffect(1.5)
Text("Planning your trip...")
.font(.headline)
.foregroundStyle(.white)
Text("Finding the best route and games")
.font(.subheadline)
.foregroundStyle(.white.opacity(0.8))
}
.padding(40)
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 20))
}
}
// MARK: - Helpers
private func binding(for sport: Sport) -> Binding<Bool> {
Binding(
get: { viewModel.selectedSports.contains(sport) },
set: { isSelected in
if isSelected {
viewModel.selectedSports.insert(sport)
} else {
viewModel.selectedSports.remove(sport)
}
}
)
}
private func buildGamesDictionary() -> [UUID: RichGame] {
Dictionary(uniqueKeysWithValues: viewModel.availableGames.map { ($0.id, $0) })
}
}
// MARK: - View State Extensions
extension TripCreationViewModel.ViewState {
var isError: Bool {
if case .error = self { return true }
return false
}
var isCompleted: Bool {
if case .completed = self { return true }
return false
}
}
// MARK: - Game Picker Sheet (Team-based selection)
struct GamePickerSheet: View {
let games: [RichGame]
@Binding var selectedIds: Set<UUID>
@Environment(\.dismiss) private var dismiss
// Group games by team (both home and away)
private var teamsList: [TeamWithGames] {
var teamsDict: [UUID: TeamWithGames] = [:]
for game in games {
// Add to home team
if var teamData = teamsDict[game.homeTeam.id] {
teamData.games.append(game)
teamsDict[game.homeTeam.id] = teamData
} else {
teamsDict[game.homeTeam.id] = TeamWithGames(
team: game.homeTeam,
sport: game.game.sport,
games: [game]
)
}
// Add to away team
if var teamData = teamsDict[game.awayTeam.id] {
teamData.games.append(game)
teamsDict[game.awayTeam.id] = teamData
} else {
teamsDict[game.awayTeam.id] = TeamWithGames(
team: game.awayTeam,
sport: game.game.sport,
games: [game]
)
}
}
return teamsDict.values
.sorted { $0.team.name < $1.team.name }
}
private var teamsBySport: [(sport: Sport, teams: [TeamWithGames])] {
let grouped = Dictionary(grouping: teamsList) { $0.sport }
return Sport.supported
.filter { grouped[$0] != nil }
.map { sport in
(sport, grouped[sport]!.sorted { $0.team.name < $1.team.name })
}
}
private var selectedGamesCount: Int {
selectedIds.count
}
var body: some View {
NavigationStack {
List {
// Selected games summary
if !selectedIds.isEmpty {
Section {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
Text("\(selectedGamesCount) game(s) selected")
.fontWeight(.medium)
Spacer()
}
}
}
// Teams by sport
ForEach(teamsBySport, id: \.sport.id) { sportGroup in
Section(sportGroup.sport.rawValue) {
ForEach(sportGroup.teams) { teamData in
NavigationLink {
TeamGamesView(
teamData: teamData,
selectedIds: $selectedIds
)
} label: {
TeamRow(teamData: teamData, selectedIds: selectedIds)
}
}
}
}
}
.navigationTitle("Select Teams")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
if !selectedIds.isEmpty {
Button("Reset") {
selectedIds.removeAll()
}
.foregroundStyle(.red)
}
}
ToolbarItem(placement: .confirmationAction) {
Button("Done") {
dismiss()
}
}
}
}
}
}
// MARK: - Team With Games Model
struct TeamWithGames: Identifiable {
let team: Team
let sport: Sport
var games: [RichGame]
var id: UUID { team.id }
var sortedGames: [RichGame] {
games.sorted { $0.game.dateTime < $1.game.dateTime }
}
}
// MARK: - Team Row
struct TeamRow: View {
let teamData: TeamWithGames
let selectedIds: Set<UUID>
private var selectedCount: Int {
teamData.games.filter { selectedIds.contains($0.id) }.count
}
var body: some View {
HStack(spacing: 12) {
// Team color indicator
if let colorHex = teamData.team.primaryColor {
Circle()
.fill(Color(hex: colorHex) ?? .gray)
.frame(width: 12, height: 12)
}
VStack(alignment: .leading, spacing: 2) {
Text("\(teamData.team.city) \(teamData.team.name)")
.font(.subheadline)
.fontWeight(.medium)
Text("\(teamData.games.count) game(s) available")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if selectedCount > 0 {
Text("\(selectedCount)")
.font(.caption)
.fontWeight(.bold)
.foregroundStyle(.white)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(.blue)
.clipShape(Capsule())
}
}
}
}
// MARK: - Team Games View
struct TeamGamesView: View {
let teamData: TeamWithGames
@Binding var selectedIds: Set<UUID>
var body: some View {
List {
ForEach(teamData.sortedGames) { game in
GameRow(game: game, isSelected: selectedIds.contains(game.id)) {
if selectedIds.contains(game.id) {
selectedIds.remove(game.id)
} else {
selectedIds.insert(game.id)
}
}
}
}
.navigationTitle("\(teamData.team.city) \(teamData.team.name)")
.navigationBarTitleDisplayMode(.inline)
}
}
struct GameRow: View {
let game: RichGame
let isSelected: Bool
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(game.matchupDescription)
.font(.headline)
Text(game.venueDescription)
.font(.caption)
.foregroundStyle(.secondary)
Text("\(game.game.formattedDate)\(game.game.gameTime)")
.font(.caption2)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
.foregroundStyle(isSelected ? .blue : .gray)
.font(.title2)
}
}
.buttonStyle(.plain)
}
}
struct GameSelectRow: View {
let game: RichGame
let isSelected: Bool
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
HStack(spacing: 12) {
// Sport icon
Image(systemName: game.game.sport.iconName)
.font(.title3)
.foregroundStyle(isSelected ? .blue : .secondary)
.frame(width: 24)
VStack(alignment: .leading, spacing: 2) {
Text("\(game.awayTeam.abbreviation) @ \(game.homeTeam.abbreviation)")
.font(.subheadline)
.fontWeight(.medium)
Text("\(game.game.formattedDate)\(game.game.gameTime)")
.font(.caption)
.foregroundStyle(.secondary)
Text(game.stadium.city)
.font(.caption2)
.foregroundStyle(.tertiary)
}
Spacer()
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
.foregroundStyle(isSelected ? .blue : .gray.opacity(0.5))
.font(.title3)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
// MARK: - Location Search Sheet
struct LocationSearchSheet: View {
let inputType: TripCreationView.CityInputType
let onAdd: (LocationInput) -> Void
@Environment(\.dismiss) private var dismiss
@State private var searchText = ""
@State private var searchResults: [LocationSearchResult] = []
@State private var isSearching = false
@State private var searchTask: Task<Void, Never>?
private let locationService = LocationService.shared
var body: some View {
NavigationStack {
VStack(spacing: 0) {
// Search field
HStack {
Image(systemName: "magnifyingglass")
.foregroundStyle(.secondary)
TextField("Search cities, addresses, places...", text: $searchText)
.textFieldStyle(.plain)
.autocorrectionDisabled()
if isSearching {
ProgressView()
.scaleEffect(0.8)
} else if !searchText.isEmpty {
Button {
searchText = ""
searchResults = []
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.secondary)
}
}
}
.padding()
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 10))
.padding()
// Results list
if searchResults.isEmpty && !searchText.isEmpty && !isSearching {
ContentUnavailableView(
"No Results",
systemImage: "mappin.slash",
description: Text("Try a different search term")
)
} else {
List(searchResults) { result in
Button {
onAdd(result.toLocationInput())
dismiss()
} label: {
HStack {
Image(systemName: "mappin.circle.fill")
.foregroundStyle(.red)
.font(.title2)
VStack(alignment: .leading) {
Text(result.name)
.foregroundStyle(.primary)
if !result.address.isEmpty {
Text(result.address)
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
Image(systemName: "plus.circle")
.foregroundStyle(.blue)
}
}
.buttonStyle(.plain)
}
.listStyle(.plain)
}
Spacer()
}
.navigationTitle(inputType == .mustStop ? "Add Must-Stop" : "Add Location")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
}
}
.presentationDetents([.large])
.onChange(of: searchText) { _, newValue in
// Debounce search
searchTask?.cancel()
searchTask = Task {
try? await Task.sleep(for: .milliseconds(300))
guard !Task.isCancelled else { return }
await performSearch(query: newValue)
}
}
}
private func performSearch(query: String) async {
guard !query.isEmpty else {
searchResults = []
return
}
isSearching = true
do {
searchResults = try await locationService.searchLocations(query)
} catch {
searchResults = []
}
isSearching = false
}
}
// MARK: - Trip Options View
struct TripOptionsView: View {
let options: [ItineraryOption]
let games: [UUID: RichGame]
let preferences: TripPreferences?
let convertToTrip: (ItineraryOption) -> Trip
@State private var selectedTrip: Trip?
@State private var showTripDetail = false
var body: some View {
ScrollView {
LazyVStack(spacing: 16) {
// Header
VStack(alignment: .leading, spacing: 8) {
Text("\(options.count) Trip Options Found")
.font(.title2)
.fontWeight(.bold)
Text("Select a trip to view details")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal)
.padding(.top)
// Options list
ForEach(Array(options.enumerated()), id: \.offset) { index, option in
TripOptionCard(
option: option,
rank: index + 1,
games: games,
onSelect: {
selectedTrip = convertToTrip(option)
showTripDetail = true
}
)
.padding(.horizontal)
}
}
.padding(.bottom)
}
.navigationTitle("Choose Your Trip")
.navigationBarTitleDisplayMode(.inline)
.navigationDestination(isPresented: $showTripDetail) {
if let trip = selectedTrip {
TripDetailView(trip: trip, games: games)
}
}
}
}
// MARK: - Trip Option Card
struct TripOptionCard: View {
let option: ItineraryOption
let rank: Int
let games: [UUID: RichGame]
let onSelect: () -> Void
private var cities: [String] {
option.stops.map { $0.city }
}
private var uniqueCities: Int {
Set(cities).count
}
private var totalGames: Int {
option.stops.flatMap { $0.games }.count
}
private var primaryCity: String {
// Find the city with most games
var cityCounts: [String: Int] = [:]
for stop in option.stops {
cityCounts[stop.city, default: 0] += stop.games.count
}
return cityCounts.max(by: { $0.value < $1.value })?.key ?? cities.first ?? "Unknown"
}
private var routeSummary: String {
let uniqueCityList = cities.removingDuplicates()
if uniqueCityList.count <= 3 {
return uniqueCityList.joined(separator: "")
}
return "\(uniqueCityList[0]) → ... → \(uniqueCityList.last ?? "")"
}
var body: some View {
Button(action: onSelect) {
VStack(alignment: .leading, spacing: 12) {
// Header with rank and primary city
HStack(alignment: .center) {
// Rank badge
Text("Option \(rank)")
.font(.caption)
.fontWeight(.bold)
.foregroundStyle(.white)
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(rank == 1 ? Color.blue : Color.gray)
.clipShape(Capsule())
Spacer()
// Primary city label
Text(primaryCity)
.font(.headline)
.foregroundStyle(.primary)
}
// Route summary
Text(routeSummary)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(1)
// Stats row
HStack(spacing: 20) {
StatPill(icon: "sportscourt.fill", value: "\(totalGames)", label: "games")
StatPill(icon: "mappin.circle.fill", value: "\(uniqueCities)", label: "cities")
StatPill(icon: "car.fill", value: formatDriving(option.totalDrivingHours), label: "driving")
}
// Games preview
if !option.stops.isEmpty {
VStack(alignment: .leading, spacing: 4) {
ForEach(option.stops.prefix(3), id: \.city) { stop in
HStack(spacing: 8) {
Circle()
.fill(Color.blue.opacity(0.3))
.frame(width: 8, height: 8)
Text(stop.city)
.font(.caption)
.fontWeight(.medium)
Text("\(stop.games.count) game\(stop.games.count == 1 ? "" : "s")")
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
}
}
if option.stops.count > 3 {
Text("+ \(option.stops.count - 3) more stops")
.font(.caption)
.foregroundStyle(.tertiary)
.padding(.leading, 16)
}
}
}
// Tap to view hint
HStack {
Spacer()
Text("Tap to view details")
.font(.caption2)
.foregroundStyle(.tertiary)
Image(systemName: "chevron.right")
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
.padding()
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay(
RoundedRectangle(cornerRadius: 12)
.strokeBorder(rank == 1 ? Color.blue.opacity(0.3) : Color.clear, lineWidth: 2)
)
}
.buttonStyle(.plain)
}
private func formatDriving(_ hours: Double) -> String {
if hours < 1 {
return "\(Int(hours * 60))m"
}
let h = Int(hours)
let m = Int((hours - Double(h)) * 60)
if m == 0 {
return "\(h)h"
}
return "\(h)h \(m)m"
}
}
// MARK: - Stat Pill
struct StatPill: View {
let icon: String
let value: String
let label: String
var body: some View {
HStack(spacing: 4) {
Image(systemName: icon)
.font(.caption2)
.foregroundStyle(.blue)
Text(value)
.font(.caption)
.fontWeight(.semibold)
Text(label)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
// MARK: - Array Extension for Removing Duplicates
extension Array where Element: Hashable {
func removingDuplicates() -> [Element] {
var seen = Set<Element>()
return filter { seen.insert($0).inserted }
}
}
#Preview {
TripCreationView()
}