- Add local canonicalization pipeline (stadiums, teams, games) that generates deterministic canonical IDs before CloudKit upload - Fix CanonicalSyncService to use deterministic UUIDs from canonical IDs instead of random UUIDs from CloudKit records - Add SyncStadium/SyncTeam/SyncGame types to CloudKitService that preserve canonical ID relationships during sync - Add canonical ID field keys to CKModels for reading from CloudKit records - Bundle canonical JSON files (stadiums_canonical, teams_canonical, games_canonical, stadium_aliases) for consistent bootstrap data - Update BootstrapService to prefer canonical format files over legacy format This ensures all entities use consistent deterministic UUIDs derived from their canonical IDs, preventing duplicate records when syncing CloudKit data with bootstrapped local data. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1986 lines
71 KiB
Swift
1986 lines
71 KiB
Swift
//
|
|
// TripCreationView.swift
|
|
// SportsTime
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
struct TripCreationView: View {
|
|
@Environment(\.dismiss) private var dismiss
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
|
|
@Bindable var viewModel: TripCreationViewModel
|
|
let initialSport: Sport?
|
|
|
|
init(viewModel: TripCreationViewModel, initialSport: Sport? = nil) {
|
|
self.viewModel = viewModel
|
|
self.initialSport = initialSport
|
|
}
|
|
@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] = []
|
|
|
|
// Location search state
|
|
@State private var startLocationSuggestions: [LocationSearchResult] = []
|
|
@State private var endLocationSuggestions: [LocationSearchResult] = []
|
|
@State private var startSearchTask: Task<Void, Never>?
|
|
@State private var endSearchTask: Task<Void, Never>?
|
|
@State private var isSearchingStart = false
|
|
@State private var isSearchingEnd = false
|
|
|
|
private let locationService = LocationService.shared
|
|
|
|
enum CityInputType {
|
|
case mustStop
|
|
case preferred
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
ScrollView {
|
|
VStack(spacing: Theme.Spacing.lg) {
|
|
// Hero header
|
|
heroHeader
|
|
|
|
// Planning Mode Selector
|
|
planningModeSection
|
|
|
|
// Location Permission Banner (only for locations mode)
|
|
if viewModel.planningMode == .locations && showLocationBanner {
|
|
LocationPermissionBanner(isPresented: $showLocationBanner)
|
|
}
|
|
|
|
// 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 {
|
|
validationBanner(message: message)
|
|
}
|
|
|
|
// Plan button
|
|
planButton
|
|
}
|
|
.padding(Theme.Spacing.md)
|
|
}
|
|
.themedBackground()
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button("Cancel") {
|
|
dismiss()
|
|
}
|
|
}
|
|
}
|
|
.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()
|
|
}
|
|
.onAppear {
|
|
if let sport = initialSport {
|
|
viewModel.selectedSports = [sport]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Hero Header
|
|
|
|
private var heroHeader: some View {
|
|
VStack(spacing: Theme.Spacing.sm) {
|
|
Image(systemName: "map.fill")
|
|
.font(.system(size: 40))
|
|
.foregroundStyle(Theme.warmOrange)
|
|
|
|
Text("Plan Your Adventure")
|
|
.font(.system(size: Theme.FontSize.sectionTitle, weight: .bold, design: .rounded))
|
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
|
|
|
Text("Select your games, set your route, and hit the road")
|
|
.font(.system(size: Theme.FontSize.caption))
|
|
.foregroundStyle(Theme.textSecondary(colorScheme))
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
.padding(.vertical, Theme.Spacing.md)
|
|
}
|
|
|
|
// MARK: - Sections
|
|
|
|
private var planningModeSection: some View {
|
|
ThemedSection(title: "How do you want to plan?") {
|
|
Picker("Planning Mode", selection: $viewModel.planningMode) {
|
|
ForEach(PlanningMode.allCases) { mode in
|
|
Text(mode.displayName).tag(mode)
|
|
}
|
|
}
|
|
.pickerStyle(.segmented)
|
|
|
|
Text(viewModel.planningMode.description)
|
|
.font(.system(size: Theme.FontSize.caption))
|
|
.foregroundStyle(Theme.textSecondary(colorScheme))
|
|
.padding(.top, Theme.Spacing.xs)
|
|
}
|
|
}
|
|
|
|
private var locationSection: some View {
|
|
ThemedSection(title: "Locations") {
|
|
// Start Location with suggestions
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
ThemedTextField(
|
|
label: "Start Location",
|
|
placeholder: "Where are you starting from?",
|
|
text: $viewModel.startLocationText,
|
|
icon: "location.circle.fill"
|
|
)
|
|
.onChange(of: viewModel.startLocationText) { _, newValue in
|
|
searchLocation(query: newValue, isStart: true)
|
|
}
|
|
|
|
// Suggestions for start location
|
|
if !startLocationSuggestions.isEmpty {
|
|
locationSuggestionsList(
|
|
suggestions: startLocationSuggestions,
|
|
isLoading: isSearchingStart
|
|
) { result in
|
|
viewModel.startLocationText = result.name
|
|
viewModel.startLocation = result.toLocationInput()
|
|
startLocationSuggestions = []
|
|
}
|
|
} else if isSearchingStart {
|
|
HStack {
|
|
ThemedSpinnerCompact(size: 14)
|
|
Text("Searching...")
|
|
.font(.system(size: Theme.FontSize.caption))
|
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
|
}
|
|
.padding(.top, Theme.Spacing.xs)
|
|
}
|
|
}
|
|
|
|
// End Location with suggestions
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
ThemedTextField(
|
|
label: "End Location",
|
|
placeholder: "Where do you want to end up?",
|
|
text: $viewModel.endLocationText,
|
|
icon: "mappin.circle.fill"
|
|
)
|
|
.onChange(of: viewModel.endLocationText) { _, newValue in
|
|
searchLocation(query: newValue, isStart: false)
|
|
}
|
|
|
|
// Suggestions for end location
|
|
if !endLocationSuggestions.isEmpty {
|
|
locationSuggestionsList(
|
|
suggestions: endLocationSuggestions,
|
|
isLoading: isSearchingEnd
|
|
) { result in
|
|
viewModel.endLocationText = result.name
|
|
viewModel.endLocation = result.toLocationInput()
|
|
endLocationSuggestions = []
|
|
}
|
|
} else if isSearchingEnd {
|
|
HStack {
|
|
ThemedSpinnerCompact(size: 14)
|
|
Text("Searching...")
|
|
.font(.system(size: Theme.FontSize.caption))
|
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
|
}
|
|
.padding(.top, Theme.Spacing.xs)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func searchLocation(query: String, isStart: Bool) {
|
|
// Cancel previous search
|
|
if isStart {
|
|
startSearchTask?.cancel()
|
|
} else {
|
|
endSearchTask?.cancel()
|
|
}
|
|
|
|
guard query.count >= 2 else {
|
|
if isStart {
|
|
startLocationSuggestions = []
|
|
isSearchingStart = false
|
|
} else {
|
|
endLocationSuggestions = []
|
|
isSearchingEnd = false
|
|
}
|
|
return
|
|
}
|
|
|
|
let task = Task {
|
|
// Debounce
|
|
try? await Task.sleep(for: .milliseconds(300))
|
|
guard !Task.isCancelled else { return }
|
|
|
|
if isStart {
|
|
isSearchingStart = true
|
|
} else {
|
|
isSearchingEnd = true
|
|
}
|
|
|
|
do {
|
|
let results = try await locationService.searchLocations(query)
|
|
guard !Task.isCancelled else { return }
|
|
|
|
if isStart {
|
|
startLocationSuggestions = Array(results.prefix(5))
|
|
isSearchingStart = false
|
|
} else {
|
|
endLocationSuggestions = Array(results.prefix(5))
|
|
isSearchingEnd = false
|
|
}
|
|
} catch {
|
|
if isStart {
|
|
startLocationSuggestions = []
|
|
isSearchingStart = false
|
|
} else {
|
|
endLocationSuggestions = []
|
|
isSearchingEnd = false
|
|
}
|
|
}
|
|
}
|
|
|
|
if isStart {
|
|
startSearchTask = task
|
|
} else {
|
|
endSearchTask = task
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func locationSuggestionsList(
|
|
suggestions: [LocationSearchResult],
|
|
isLoading: Bool,
|
|
onSelect: @escaping (LocationSearchResult) -> Void
|
|
) -> some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
ForEach(suggestions) { result in
|
|
Button {
|
|
onSelect(result)
|
|
} label: {
|
|
HStack(spacing: Theme.Spacing.sm) {
|
|
Image(systemName: "mappin.circle.fill")
|
|
.foregroundStyle(Theme.warmOrange)
|
|
.font(.system(size: 14))
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(result.name)
|
|
.font(.system(size: Theme.FontSize.caption, weight: .medium))
|
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
|
|
|
if !result.address.isEmpty {
|
|
Text(result.address)
|
|
.font(.system(size: Theme.FontSize.micro))
|
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
}
|
|
.padding(.vertical, Theme.Spacing.sm)
|
|
.padding(.horizontal, Theme.Spacing.xs)
|
|
}
|
|
.buttonStyle(.plain)
|
|
|
|
if result.id != suggestions.last?.id {
|
|
Divider()
|
|
.overlay(Theme.surfaceGlow(colorScheme))
|
|
}
|
|
}
|
|
}
|
|
.padding(.top, Theme.Spacing.xs)
|
|
.background(Theme.cardBackgroundElevated(colorScheme))
|
|
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.small))
|
|
}
|
|
|
|
private var gameBrowserSection: some View {
|
|
ThemedSection(title: "Select Games") {
|
|
if viewModel.isLoadingGames || viewModel.availableGames.isEmpty {
|
|
HStack(spacing: Theme.Spacing.sm) {
|
|
ThemedSpinnerCompact(size: 20)
|
|
Text("Loading games...")
|
|
.font(.system(size: Theme.FontSize.body))
|
|
.foregroundStyle(Theme.textSecondary(colorScheme))
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .center)
|
|
.padding(.vertical, Theme.Spacing.md)
|
|
.task(id: viewModel.selectedSports) {
|
|
// Re-run when sports selection changes
|
|
if viewModel.availableGames.isEmpty {
|
|
await viewModel.loadGamesForBrowsing()
|
|
}
|
|
}
|
|
} else {
|
|
Button {
|
|
showGamePicker = true
|
|
} label: {
|
|
HStack(spacing: Theme.Spacing.md) {
|
|
ZStack {
|
|
Circle()
|
|
.fill(Theme.warmOrange.opacity(0.15))
|
|
.frame(width: 44, height: 44)
|
|
Image(systemName: "sportscourt.fill")
|
|
.foregroundStyle(Theme.warmOrange)
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text("Browse Teams & Games")
|
|
.font(.system(size: Theme.FontSize.body, weight: .semibold))
|
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
|
Text("\(viewModel.availableGames.count) games available")
|
|
.font(.system(size: Theme.FontSize.caption))
|
|
.foregroundStyle(Theme.textSecondary(colorScheme))
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Image(systemName: "chevron.right")
|
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
|
}
|
|
.padding(Theme.Spacing.md)
|
|
.background(Theme.cardBackgroundElevated(colorScheme))
|
|
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
// Show selected games summary
|
|
if !viewModel.mustSeeGameIds.isEmpty {
|
|
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
|
HStack {
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.foregroundStyle(.green)
|
|
Text("\(viewModel.mustSeeGameIds.count) game(s) selected")
|
|
.font(.system(size: Theme.FontSize.body, weight: .medium))
|
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
|
|
|
Spacer()
|
|
|
|
Button {
|
|
viewModel.deselectAllGames()
|
|
} label: {
|
|
Text("Deselect All")
|
|
.font(.system(size: Theme.FontSize.caption, weight: .medium))
|
|
.foregroundStyle(.red)
|
|
}
|
|
}
|
|
|
|
// Show selected games preview
|
|
ForEach(viewModel.selectedGames.prefix(3)) { game in
|
|
HStack(spacing: Theme.Spacing.sm) {
|
|
SportColorBar(sport: game.game.sport)
|
|
Text("\(game.awayTeam.abbreviation) @ \(game.homeTeam.abbreviation)")
|
|
.font(.system(size: Theme.FontSize.caption))
|
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
|
Spacer()
|
|
Text(game.game.formattedDate)
|
|
.font(.system(size: Theme.FontSize.micro))
|
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
|
}
|
|
}
|
|
|
|
if viewModel.selectedGames.count > 3 {
|
|
Text("+ \(viewModel.selectedGames.count - 3) more")
|
|
.font(.system(size: Theme.FontSize.caption))
|
|
.foregroundStyle(Theme.textSecondary(colorScheme))
|
|
}
|
|
}
|
|
.padding(Theme.Spacing.md)
|
|
.background(Theme.cardBackgroundElevated(colorScheme))
|
|
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
|
|
}
|
|
}
|
|
}
|
|
|
|
private var tripBufferSection: some View {
|
|
ThemedSection(title: "Trip Duration") {
|
|
ThemedStepper(
|
|
label: "Buffer Days",
|
|
value: viewModel.tripBufferDays,
|
|
range: 0...7,
|
|
onIncrement: { viewModel.tripBufferDays += 1 },
|
|
onDecrement: { viewModel.tripBufferDays -= 1 }
|
|
)
|
|
|
|
if let dateRange = viewModel.gameFirstDateRange {
|
|
HStack {
|
|
Text("Trip window:")
|
|
.font(.system(size: Theme.FontSize.caption))
|
|
.foregroundStyle(Theme.textSecondary(colorScheme))
|
|
Spacer()
|
|
Text("\(dateRange.start.formatted(date: .abbreviated, time: .omitted)) - \(dateRange.end.formatted(date: .abbreviated, time: .omitted))")
|
|
.font(.system(size: Theme.FontSize.caption, weight: .medium))
|
|
.foregroundStyle(Theme.warmOrange)
|
|
}
|
|
}
|
|
|
|
Text("Days before first game and after last game for travel/rest")
|
|
.font(.system(size: Theme.FontSize.micro))
|
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
|
}
|
|
}
|
|
|
|
private var sportsSection: some View {
|
|
ThemedSection(title: "Sports") {
|
|
HStack(spacing: Theme.Spacing.sm) {
|
|
ForEach(Sport.supported) { sport in
|
|
SportSelectionChip(
|
|
sport: sport,
|
|
isSelected: viewModel.selectedSports.contains(sport),
|
|
onTap: {
|
|
if viewModel.selectedSports.contains(sport) {
|
|
viewModel.selectedSports.remove(sport)
|
|
} else {
|
|
viewModel.selectedSports.insert(sport)
|
|
}
|
|
}
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var datesSection: some View {
|
|
ThemedSection(title: "Dates") {
|
|
DateRangePicker(
|
|
startDate: $viewModel.startDate,
|
|
endDate: $viewModel.endDate
|
|
)
|
|
}
|
|
}
|
|
|
|
private var gamesSection: some View {
|
|
ThemedSection(title: "Must-See Games") {
|
|
Button {
|
|
showGamePicker = true
|
|
} label: {
|
|
HStack(spacing: Theme.Spacing.md) {
|
|
ZStack {
|
|
Circle()
|
|
.fill(Theme.warmOrange.opacity(0.15))
|
|
.frame(width: 40, height: 40)
|
|
Image(systemName: "star.fill")
|
|
.foregroundStyle(Theme.warmOrange)
|
|
}
|
|
|
|
Text("Select Games")
|
|
.font(.system(size: Theme.FontSize.body, weight: .medium))
|
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
|
|
|
Spacer()
|
|
|
|
Text("\(viewModel.selectedGamesCount) selected")
|
|
.font(.system(size: Theme.FontSize.caption))
|
|
.foregroundStyle(Theme.textSecondary(colorScheme))
|
|
|
|
Image(systemName: "chevron.right")
|
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
|
}
|
|
.padding(Theme.Spacing.md)
|
|
.background(Theme.cardBackgroundElevated(colorScheme))
|
|
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
|
|
private var travelSection: some View {
|
|
ThemedSection(title: "Travel") {
|
|
VStack(spacing: Theme.Spacing.md) {
|
|
// Route preference
|
|
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
|
|
Text("Route Preference")
|
|
.font(.system(size: Theme.FontSize.caption))
|
|
.foregroundStyle(Theme.textSecondary(colorScheme))
|
|
|
|
Picker("Route Preference", selection: $viewModel.routePreference) {
|
|
ForEach(RoutePreference.allCases) { pref in
|
|
Text(pref.displayName).tag(pref)
|
|
}
|
|
}
|
|
.pickerStyle(.segmented)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var constraintsSection: some View {
|
|
ThemedSection(title: "Trip Style") {
|
|
VStack(spacing: Theme.Spacing.md) {
|
|
ThemedToggle(
|
|
label: "Limit Cities",
|
|
isOn: $viewModel.useStopCount,
|
|
icon: "mappin.and.ellipse"
|
|
)
|
|
|
|
if viewModel.useStopCount {
|
|
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
|
|
ThemedStepper(
|
|
label: "Number of Cities",
|
|
value: viewModel.numberOfStops,
|
|
range: 1...20,
|
|
onIncrement: { viewModel.numberOfStops += 1 },
|
|
onDecrement: { viewModel.numberOfStops -= 1 }
|
|
)
|
|
|
|
Text("How many different cities to visit on your trip. More cities = more variety, but more driving between them.")
|
|
.font(.system(size: Theme.FontSize.micro))
|
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
|
}
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
|
|
Text("Trip Pace")
|
|
.font(.system(size: Theme.FontSize.caption))
|
|
.foregroundStyle(Theme.textSecondary(colorScheme))
|
|
|
|
Picker("Pace", selection: $viewModel.leisureLevel) {
|
|
ForEach(LeisureLevel.allCases) { level in
|
|
Text(level.displayName).tag(level)
|
|
}
|
|
}
|
|
.pickerStyle(.segmented)
|
|
|
|
Text(viewModel.leisureLevel.description)
|
|
.font(.system(size: Theme.FontSize.micro))
|
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
|
.padding(.top, Theme.Spacing.xxs)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var optionalSection: some View {
|
|
ThemedSection(title: "More Options") {
|
|
VStack(spacing: Theme.Spacing.md) {
|
|
// Must-Stop Locations
|
|
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
|
HStack {
|
|
Image(systemName: "mappin.circle")
|
|
.foregroundStyle(Theme.warmOrange)
|
|
Text("Must-Stop Locations")
|
|
.font(.system(size: Theme.FontSize.body, weight: .medium))
|
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
|
Spacer()
|
|
Text("\(viewModel.mustStopLocations.count)")
|
|
.font(.system(size: Theme.FontSize.caption))
|
|
.foregroundStyle(Theme.textSecondary(colorScheme))
|
|
}
|
|
|
|
ForEach(viewModel.mustStopLocations, id: \.name) { location in
|
|
HStack {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(location.name)
|
|
.font(.system(size: Theme.FontSize.caption))
|
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
|
if let address = location.address, !address.isEmpty {
|
|
Text(address)
|
|
.font(.system(size: Theme.FontSize.micro))
|
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
|
}
|
|
}
|
|
Spacer()
|
|
Button {
|
|
viewModel.removeMustStopLocation(location)
|
|
} label: {
|
|
Image(systemName: "xmark.circle.fill")
|
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
|
}
|
|
}
|
|
.padding(Theme.Spacing.sm)
|
|
.background(Theme.cardBackgroundElevated(colorScheme))
|
|
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.small))
|
|
}
|
|
|
|
Button {
|
|
cityInputType = .mustStop
|
|
showCityInput = true
|
|
} label: {
|
|
HStack {
|
|
Image(systemName: "plus.circle.fill")
|
|
Text("Add Location")
|
|
}
|
|
.font(.system(size: Theme.FontSize.caption, weight: .medium))
|
|
.foregroundStyle(Theme.warmOrange)
|
|
}
|
|
}
|
|
|
|
Divider()
|
|
.overlay(Theme.surfaceGlow(colorScheme))
|
|
|
|
// EV Charging (feature flagged)
|
|
if FeatureFlags.enableEVCharging {
|
|
ThemedToggle(
|
|
label: "EV Charging Needed",
|
|
isOn: $viewModel.needsEVCharging,
|
|
icon: "bolt.car"
|
|
)
|
|
}
|
|
|
|
// Drivers
|
|
ThemedStepper(
|
|
label: "Number of Drivers",
|
|
value: viewModel.numberOfDrivers,
|
|
range: 1...4,
|
|
onIncrement: { viewModel.numberOfDrivers += 1 },
|
|
onDecrement: { viewModel.numberOfDrivers -= 1 }
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var planningOverlay: some View {
|
|
ZStack {
|
|
Color.black.opacity(0.5)
|
|
.ignoresSafeArea()
|
|
|
|
PlanningProgressView()
|
|
.padding(40)
|
|
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 24))
|
|
}
|
|
}
|
|
|
|
private var planButton: some View {
|
|
Button {
|
|
Task {
|
|
await viewModel.planTrip()
|
|
}
|
|
} label: {
|
|
HStack(spacing: Theme.Spacing.sm) {
|
|
Image(systemName: "map.fill")
|
|
Text("Plan My Trip")
|
|
.fontWeight(.semibold)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(Theme.Spacing.md)
|
|
.background(viewModel.isFormValid ? Theme.warmOrange : Theme.textMuted(colorScheme))
|
|
.foregroundStyle(.white)
|
|
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
|
|
}
|
|
.disabled(!viewModel.isFormValid)
|
|
.padding(.top, Theme.Spacing.md)
|
|
.glowEffect(color: viewModel.isFormValid ? Theme.warmOrange : .clear, radius: 12)
|
|
}
|
|
|
|
private func validationBanner(message: String) -> some View {
|
|
HStack(spacing: Theme.Spacing.sm) {
|
|
Image(systemName: "exclamationmark.triangle.fill")
|
|
.foregroundStyle(.orange)
|
|
Text(message)
|
|
.font(.system(size: Theme.FontSize.caption))
|
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
|
}
|
|
.padding(Theme.Spacing.md)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(Color.orange.opacity(0.15))
|
|
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
|
|
}
|
|
|
|
// 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] {
|
|
viewModel.availableGames.reduce(into: [:]) { $0[$1.id] = $1 }
|
|
}
|
|
}
|
|
|
|
// 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))
|
|
.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
|
|
GamePickerRow(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 GamePickerRow: View {
|
|
let game: RichGame
|
|
let isSelected: Bool
|
|
let onTap: () -> Void
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
|
|
var body: some View {
|
|
Button(action: onTap) {
|
|
HStack(spacing: 12) {
|
|
// Sport color bar
|
|
SportColorBar(sport: game.game.sport)
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(game.matchupDescription)
|
|
.font(.system(size: 15, weight: .semibold))
|
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
|
|
|
Text(game.venueDescription)
|
|
.font(.caption)
|
|
.foregroundStyle(Theme.textSecondary(colorScheme))
|
|
|
|
Text("\(game.game.formattedDate) • \(game.game.gameTime)")
|
|
.font(.caption2)
|
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
|
|
.foregroundStyle(isSelected ? Theme.warmOrange : Theme.textMuted(colorScheme))
|
|
.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 {
|
|
ThemedSpinnerCompact(size: 16)
|
|
} 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
|
|
|
|
// MARK: - Sort Options
|
|
|
|
enum TripSortOption: String, CaseIterable, Identifiable {
|
|
case recommended = "Recommended"
|
|
case mostGames = "Most Games"
|
|
case leastGames = "Least Games"
|
|
case mostMiles = "Most Miles"
|
|
case leastMiles = "Least Miles"
|
|
case bestEfficiency = "Best Efficiency"
|
|
|
|
var id: String { rawValue }
|
|
|
|
var icon: String {
|
|
switch self {
|
|
case .recommended: return "star.fill"
|
|
case .mostGames, .leastGames: return "sportscourt"
|
|
case .mostMiles, .leastMiles: return "road.lanes"
|
|
case .bestEfficiency: return "gauge.with.dots.needle.33percent"
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
@State private var sortOption: TripSortOption = .recommended
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
|
|
private var sortedOptions: [ItineraryOption] {
|
|
switch sortOption {
|
|
case .recommended:
|
|
return options
|
|
case .mostGames:
|
|
return options.sorted { $0.totalGames > $1.totalGames }
|
|
case .leastGames:
|
|
return options.sorted { $0.totalGames < $1.totalGames }
|
|
case .mostMiles:
|
|
return options.sorted { $0.totalDistanceMiles > $1.totalDistanceMiles }
|
|
case .leastMiles:
|
|
return options.sorted { $0.totalDistanceMiles < $1.totalDistanceMiles }
|
|
case .bestEfficiency:
|
|
// Games per driving hour (higher is better)
|
|
return options.sorted {
|
|
let effA = $0.totalDrivingHours > 0 ? Double($0.totalGames) / $0.totalDrivingHours : 0
|
|
let effB = $1.totalDrivingHours > 0 ? Double($1.totalGames) / $1.totalDrivingHours : 0
|
|
return effA > effB
|
|
}
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
LazyVStack(spacing: 20) {
|
|
// Hero header
|
|
VStack(spacing: 12) {
|
|
Image(systemName: "point.topright.arrow.triangle.backward.to.point.bottomleft.scurvepath.fill")
|
|
.font(.system(size: 44))
|
|
.foregroundStyle(Theme.warmOrange)
|
|
|
|
Text("\(options.count) Routes Found")
|
|
.font(.system(size: Theme.FontSize.sectionTitle, weight: .bold, design: .rounded))
|
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
|
|
|
Text("Each route offers a unique adventure")
|
|
.font(.system(size: Theme.FontSize.body))
|
|
.foregroundStyle(Theme.textSecondary(colorScheme))
|
|
}
|
|
.padding(.top, Theme.Spacing.xl)
|
|
.padding(.bottom, Theme.Spacing.sm)
|
|
|
|
// Sort picker
|
|
sortPicker
|
|
.padding(.horizontal, Theme.Spacing.md)
|
|
.padding(.bottom, Theme.Spacing.sm)
|
|
|
|
// Options list
|
|
ForEach(sortedOptions) { option in
|
|
TripOptionCard(
|
|
option: option,
|
|
games: games,
|
|
onSelect: {
|
|
selectedTrip = convertToTrip(option)
|
|
showTripDetail = true
|
|
}
|
|
)
|
|
.padding(.horizontal, Theme.Spacing.md)
|
|
}
|
|
}
|
|
.padding(.bottom, Theme.Spacing.xxl)
|
|
}
|
|
.themedBackground()
|
|
.navigationDestination(isPresented: $showTripDetail) {
|
|
if let trip = selectedTrip {
|
|
TripDetailView(trip: trip, games: games)
|
|
}
|
|
}
|
|
.onChange(of: showTripDetail) { _, isShowing in
|
|
if !isShowing {
|
|
selectedTrip = nil
|
|
}
|
|
}
|
|
}
|
|
|
|
private var sortPicker: some View {
|
|
Menu {
|
|
ForEach(TripSortOption.allCases) { option in
|
|
Button {
|
|
withAnimation(.easeInOut(duration: 0.2)) {
|
|
sortOption = option
|
|
}
|
|
} label: {
|
|
Label(option.rawValue, systemImage: option.icon)
|
|
}
|
|
}
|
|
} label: {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: sortOption.icon)
|
|
.font(.system(size: 14))
|
|
Text(sortOption.rawValue)
|
|
.font(.system(size: 14, weight: .medium))
|
|
Image(systemName: "chevron.down")
|
|
.font(.system(size: 12))
|
|
}
|
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 10)
|
|
.background(Theme.cardBackground(colorScheme))
|
|
.clipShape(Capsule())
|
|
.overlay(
|
|
Capsule()
|
|
.strokeBorder(Theme.textMuted(colorScheme).opacity(0.2), lineWidth: 1)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Trip Option Card
|
|
|
|
struct TripOptionCard: View {
|
|
let option: ItineraryOption
|
|
let games: [UUID: RichGame]
|
|
let onSelect: () -> Void
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
|
|
@State private var aiDescription: String?
|
|
@State private var isLoadingDescription = false
|
|
|
|
private var uniqueCities: [String] {
|
|
option.stops.map { $0.city }.removingDuplicates()
|
|
}
|
|
|
|
private var totalGames: Int {
|
|
option.stops.flatMap { $0.games }.count
|
|
}
|
|
|
|
private var uniqueSports: [Sport] {
|
|
let gameIds = option.stops.flatMap { $0.games }
|
|
let sports = gameIds.compactMap { games[$0]?.game.sport }
|
|
return Array(Set(sports)).sorted { $0.rawValue < $1.rawValue }
|
|
}
|
|
|
|
private var gamesPerSport: [(sport: Sport, count: Int)] {
|
|
let gameIds = option.stops.flatMap { $0.games }
|
|
var countsBySport: [Sport: Int] = [:]
|
|
for gameId in gameIds {
|
|
if let sport = games[gameId]?.game.sport {
|
|
countsBySport[sport, default: 0] += 1
|
|
}
|
|
}
|
|
return countsBySport.sorted { $0.key.rawValue < $1.key.rawValue }
|
|
.map { (sport: $0.key, count: $0.value) }
|
|
}
|
|
|
|
var body: some View {
|
|
Button(action: onSelect) {
|
|
HStack(spacing: Theme.Spacing.md) {
|
|
// Route info
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
// Vertical route display
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
Text(uniqueCities.first ?? "")
|
|
.font(.system(size: 15, weight: .semibold))
|
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
|
|
|
VStack(spacing: 0) {
|
|
Text("|")
|
|
.font(.system(size: 10))
|
|
Image(systemName: "chevron.down")
|
|
.font(.system(size: 8, weight: .bold))
|
|
}
|
|
.foregroundStyle(Theme.warmOrange)
|
|
|
|
Text(uniqueCities.last ?? "")
|
|
.font(.system(size: 15, weight: .semibold))
|
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
|
}
|
|
|
|
// Top stats row: cities and miles
|
|
HStack(spacing: 12) {
|
|
Label("\(uniqueCities.count) cities", systemImage: "mappin")
|
|
if option.totalDistanceMiles > 0 {
|
|
Label("\(Int(option.totalDistanceMiles)) mi", systemImage: "car")
|
|
}
|
|
}
|
|
.font(.system(size: 12))
|
|
.foregroundStyle(Theme.textSecondary(colorScheme))
|
|
|
|
// Bottom row: sports with game counts
|
|
HStack(spacing: 6) {
|
|
ForEach(gamesPerSport, id: \.sport) { item in
|
|
HStack(spacing: 3) {
|
|
Image(systemName: item.sport.iconName)
|
|
.font(.system(size: 9))
|
|
Text("\(item.sport.rawValue.uppercased()) \(item.count)")
|
|
.font(.system(size: 9, weight: .semibold))
|
|
}
|
|
.padding(.horizontal, 6)
|
|
.padding(.vertical, 3)
|
|
.background(item.sport.themeColor.opacity(0.15))
|
|
.foregroundStyle(item.sport.themeColor)
|
|
.clipShape(Capsule())
|
|
}
|
|
}
|
|
|
|
// AI-generated description (after stats)
|
|
if let description = aiDescription {
|
|
Text(description)
|
|
.font(.system(size: 13, weight: .regular))
|
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
.transition(.opacity)
|
|
} else if isLoadingDescription {
|
|
HStack(spacing: 4) {
|
|
ThemedSpinnerCompact(size: 12)
|
|
Text("Generating...")
|
|
.font(.system(size: 11))
|
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
|
}
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
|
|
// Right: Chevron
|
|
Image(systemName: "chevron.right")
|
|
.font(.system(size: 14, weight: .semibold))
|
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
|
}
|
|
.padding(Theme.Spacing.md)
|
|
.background(Theme.cardBackground(colorScheme))
|
|
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
|
|
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
|
|
}
|
|
}
|
|
.buttonStyle(.plain)
|
|
.task(id: option.id) {
|
|
// Reset state when option changes
|
|
aiDescription = nil
|
|
isLoadingDescription = false
|
|
await generateDescription()
|
|
}
|
|
}
|
|
|
|
private func generateDescription() async {
|
|
guard RouteDescriptionGenerator.shared.isAvailable else { return }
|
|
|
|
isLoadingDescription = true
|
|
|
|
// Build input from THIS specific option
|
|
let input = RouteDescriptionInput(from: option, games: games)
|
|
|
|
if let description = await RouteDescriptionGenerator.shared.generateDescription(for: input) {
|
|
withAnimation(.easeInOut(duration: 0.3)) {
|
|
aiDescription = description
|
|
}
|
|
}
|
|
isLoadingDescription = false
|
|
}
|
|
}
|
|
|
|
// MARK: - Array Extension for Removing Duplicates
|
|
|
|
extension Array where Element: Hashable {
|
|
func removingDuplicates() -> [Element] {
|
|
var seen = Set<Element>()
|
|
return filter { seen.insert($0).inserted }
|
|
}
|
|
}
|
|
|
|
// MARK: - Themed Form Components
|
|
|
|
struct ThemedSection<Content: View>: View {
|
|
let title: String
|
|
@ViewBuilder let content: () -> Content
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
|
Text(title)
|
|
.font(.system(size: Theme.FontSize.sectionTitle, weight: .bold, design: .rounded))
|
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
|
|
|
VStack(alignment: .leading, spacing: Theme.Spacing.md) {
|
|
content()
|
|
}
|
|
.padding(Theme.Spacing.lg)
|
|
.background(Theme.cardBackground(colorScheme))
|
|
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large))
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: Theme.CornerRadius.large)
|
|
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct ThemedTextField: View {
|
|
let label: String
|
|
let placeholder: String
|
|
@Binding var text: String
|
|
var icon: String = "mappin"
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
|
|
Text(label)
|
|
.font(.system(size: Theme.FontSize.caption))
|
|
.foregroundStyle(Theme.textSecondary(colorScheme))
|
|
|
|
HStack(spacing: Theme.Spacing.sm) {
|
|
Image(systemName: icon)
|
|
.foregroundStyle(Theme.warmOrange)
|
|
.frame(width: 24)
|
|
|
|
TextField(placeholder, text: $text)
|
|
.font(.system(size: Theme.FontSize.body))
|
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
|
}
|
|
.padding(Theme.Spacing.md)
|
|
.background(Theme.cardBackgroundElevated(colorScheme))
|
|
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
|
|
}
|
|
}
|
|
}
|
|
|
|
struct ThemedToggle: View {
|
|
let label: String
|
|
@Binding var isOn: Bool
|
|
var icon: String = "checkmark.circle"
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
|
|
var body: some View {
|
|
HStack(spacing: Theme.Spacing.sm) {
|
|
Image(systemName: icon)
|
|
.foregroundStyle(isOn ? Theme.warmOrange : Theme.textMuted(colorScheme))
|
|
.frame(width: 24)
|
|
|
|
Text(label)
|
|
.font(.system(size: Theme.FontSize.body))
|
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
|
|
|
Spacer()
|
|
|
|
Toggle("", isOn: $isOn)
|
|
.labelsHidden()
|
|
.tint(Theme.warmOrange)
|
|
}
|
|
}
|
|
}
|
|
|
|
struct ThemedStepper: View {
|
|
let label: String
|
|
let value: Int
|
|
let range: ClosedRange<Int>
|
|
let onIncrement: () -> Void
|
|
let onDecrement: () -> Void
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
|
|
var body: some View {
|
|
HStack {
|
|
Text(label)
|
|
.font(.system(size: Theme.FontSize.body))
|
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
|
|
|
Spacer()
|
|
|
|
HStack(spacing: Theme.Spacing.sm) {
|
|
Button {
|
|
if value > range.lowerBound {
|
|
onDecrement()
|
|
}
|
|
} label: {
|
|
Image(systemName: "minus.circle.fill")
|
|
.font(.title2)
|
|
.foregroundStyle(value > range.lowerBound ? Theme.warmOrange : Theme.textMuted(colorScheme))
|
|
}
|
|
.disabled(value <= range.lowerBound)
|
|
|
|
Text("\(value)")
|
|
.font(.system(size: Theme.FontSize.body, weight: .bold))
|
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
|
.frame(minWidth: 30)
|
|
|
|
Button {
|
|
if value < range.upperBound {
|
|
onIncrement()
|
|
}
|
|
} label: {
|
|
Image(systemName: "plus.circle.fill")
|
|
.font(.title2)
|
|
.foregroundStyle(value < range.upperBound ? Theme.warmOrange : Theme.textMuted(colorScheme))
|
|
}
|
|
.disabled(value >= range.upperBound)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct ThemedDatePicker: View {
|
|
let label: String
|
|
@Binding var selection: Date
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
|
|
var body: some View {
|
|
HStack {
|
|
HStack(spacing: Theme.Spacing.sm) {
|
|
Image(systemName: "calendar")
|
|
.foregroundStyle(Theme.warmOrange)
|
|
Text(label)
|
|
.font(.system(size: Theme.FontSize.body))
|
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
|
}
|
|
|
|
Spacer()
|
|
|
|
DatePicker("", selection: $selection, displayedComponents: .date)
|
|
.labelsHidden()
|
|
.tint(Theme.warmOrange)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Date Range Picker
|
|
|
|
struct DateRangePicker: View {
|
|
@Binding var startDate: Date
|
|
@Binding var endDate: Date
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
|
|
@State private var displayedMonth: Date = Date()
|
|
@State private var selectionState: SelectionState = .none
|
|
|
|
enum SelectionState {
|
|
case none
|
|
case startSelected
|
|
case complete
|
|
}
|
|
|
|
private let calendar = Calendar.current
|
|
private let daysOfWeek = ["S", "M", "T", "W", "T", "F", "S"]
|
|
|
|
private var monthYearString: String {
|
|
let formatter = DateFormatter()
|
|
formatter.dateFormat = "MMMM yyyy"
|
|
return formatter.string(from: displayedMonth)
|
|
}
|
|
|
|
private var daysInMonth: [Date?] {
|
|
guard let monthInterval = calendar.dateInterval(of: .month, for: displayedMonth),
|
|
let monthFirstWeek = calendar.dateInterval(of: .weekOfMonth, for: monthInterval.start) else {
|
|
return []
|
|
}
|
|
|
|
var days: [Date?] = []
|
|
let startOfMonth = monthInterval.start
|
|
let endOfMonth = calendar.date(byAdding: .day, value: -1, to: monthInterval.end)!
|
|
|
|
// Get the first day of the week containing the first day of the month
|
|
var currentDate = monthFirstWeek.start
|
|
|
|
// Add days until we've covered the month
|
|
while currentDate <= endOfMonth || days.count % 7 != 0 {
|
|
if currentDate >= startOfMonth && currentDate <= endOfMonth {
|
|
days.append(currentDate)
|
|
} else if currentDate < startOfMonth {
|
|
days.append(nil) // Placeholder for days before month starts
|
|
} else if days.count % 7 != 0 {
|
|
days.append(nil) // Placeholder to complete the last week
|
|
} else {
|
|
break
|
|
}
|
|
currentDate = calendar.date(byAdding: .day, value: 1, to: currentDate)!
|
|
}
|
|
|
|
return days
|
|
}
|
|
|
|
private var tripDuration: Int {
|
|
let components = calendar.dateComponents([.day], from: startDate, to: endDate)
|
|
return (components.day ?? 0) + 1
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(spacing: Theme.Spacing.md) {
|
|
// Selected range summary
|
|
selectedRangeSummary
|
|
|
|
// Month navigation
|
|
monthNavigation
|
|
|
|
// Days of week header
|
|
daysOfWeekHeader
|
|
|
|
// Calendar grid
|
|
calendarGrid
|
|
|
|
// Trip duration
|
|
tripDurationBadge
|
|
}
|
|
}
|
|
|
|
private var selectedRangeSummary: some View {
|
|
HStack(spacing: Theme.Spacing.md) {
|
|
// Start date
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text("START")
|
|
.font(.system(size: 10, weight: .semibold))
|
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
|
Text(startDate.formatted(.dateTime.month(.abbreviated).day().year()))
|
|
.font(.system(size: Theme.FontSize.body, weight: .semibold))
|
|
.foregroundStyle(Theme.warmOrange)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
// Arrow
|
|
Image(systemName: "arrow.right")
|
|
.font(.system(size: 14, weight: .medium))
|
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
|
|
|
// End date
|
|
VStack(alignment: .trailing, spacing: 4) {
|
|
Text("END")
|
|
.font(.system(size: 10, weight: .semibold))
|
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
|
Text(endDate.formatted(.dateTime.month(.abbreviated).day().year()))
|
|
.font(.system(size: Theme.FontSize.body, weight: .semibold))
|
|
.foregroundStyle(Theme.warmOrange)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .trailing)
|
|
}
|
|
.padding(Theme.Spacing.md)
|
|
.background(Theme.cardBackgroundElevated(colorScheme))
|
|
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
|
|
}
|
|
|
|
private var monthNavigation: some View {
|
|
HStack {
|
|
Button {
|
|
withAnimation(.easeInOut(duration: 0.2)) {
|
|
displayedMonth = calendar.date(byAdding: .month, value: -1, to: displayedMonth) ?? displayedMonth
|
|
}
|
|
} label: {
|
|
Image(systemName: "chevron.left")
|
|
.font(.system(size: 16, weight: .semibold))
|
|
.foregroundStyle(Theme.warmOrange)
|
|
.frame(width: 36, height: 36)
|
|
.background(Theme.warmOrange.opacity(0.15))
|
|
.clipShape(Circle())
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Text(monthYearString)
|
|
.font(.system(size: Theme.FontSize.cardTitle, weight: .bold, design: .rounded))
|
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
|
|
|
Spacer()
|
|
|
|
Button {
|
|
withAnimation(.easeInOut(duration: 0.2)) {
|
|
displayedMonth = calendar.date(byAdding: .month, value: 1, to: displayedMonth) ?? displayedMonth
|
|
}
|
|
} label: {
|
|
Image(systemName: "chevron.right")
|
|
.font(.system(size: 16, weight: .semibold))
|
|
.foregroundStyle(Theme.warmOrange)
|
|
.frame(width: 36, height: 36)
|
|
.background(Theme.warmOrange.opacity(0.15))
|
|
.clipShape(Circle())
|
|
}
|
|
}
|
|
}
|
|
|
|
private var daysOfWeekHeader: some View {
|
|
HStack(spacing: 0) {
|
|
ForEach(Array(daysOfWeek.enumerated()), id: \.offset) { _, day in
|
|
Text(day)
|
|
.font(.system(size: 12, weight: .semibold))
|
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var calendarGrid: some View {
|
|
let columns = Array(repeating: GridItem(.flexible(), spacing: 4), count: 7)
|
|
|
|
return LazyVGrid(columns: columns, spacing: 4) {
|
|
ForEach(Array(daysInMonth.enumerated()), id: \.offset) { _, date in
|
|
if let date = date {
|
|
DayCell(
|
|
date: date,
|
|
isStart: calendar.isDate(date, inSameDayAs: startDate),
|
|
isEnd: calendar.isDate(date, inSameDayAs: endDate),
|
|
isInRange: isDateInRange(date),
|
|
isToday: calendar.isDateInToday(date),
|
|
onTap: { handleDateTap(date) }
|
|
)
|
|
} else {
|
|
Color.clear
|
|
.frame(height: 40)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var tripDurationBadge: some View {
|
|
HStack(spacing: Theme.Spacing.xs) {
|
|
Image(systemName: "calendar.badge.clock")
|
|
.foregroundStyle(Theme.warmOrange)
|
|
Text("\(tripDuration) day\(tripDuration == 1 ? "" : "s")")
|
|
.font(.system(size: Theme.FontSize.caption, weight: .medium))
|
|
.foregroundStyle(Theme.textSecondary(colorScheme))
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .center)
|
|
.padding(.top, Theme.Spacing.xs)
|
|
}
|
|
|
|
private func isDateInRange(_ date: Date) -> Bool {
|
|
let start = calendar.startOfDay(for: startDate)
|
|
let end = calendar.startOfDay(for: endDate)
|
|
let current = calendar.startOfDay(for: date)
|
|
return current > start && current < end
|
|
}
|
|
|
|
private func handleDateTap(_ date: Date) {
|
|
let today = calendar.startOfDay(for: Date())
|
|
let tappedDate = calendar.startOfDay(for: date)
|
|
|
|
// Don't allow selecting dates in the past
|
|
if tappedDate < today {
|
|
return
|
|
}
|
|
|
|
switch selectionState {
|
|
case .none, .complete:
|
|
// First tap: set start date, reset end to same day
|
|
startDate = date
|
|
endDate = date
|
|
selectionState = .startSelected
|
|
|
|
case .startSelected:
|
|
// Second tap: set end date (if after start)
|
|
if date >= startDate {
|
|
endDate = date
|
|
} else {
|
|
// If tapped date is before start, make it the new start
|
|
endDate = startDate
|
|
startDate = date
|
|
}
|
|
selectionState = .complete
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Day Cell
|
|
|
|
struct DayCell: View {
|
|
let date: Date
|
|
let isStart: Bool
|
|
let isEnd: Bool
|
|
let isInRange: Bool
|
|
let isToday: Bool
|
|
let onTap: () -> Void
|
|
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
|
|
private let calendar = Calendar.current
|
|
|
|
private var dayNumber: String {
|
|
"\(calendar.component(.day, from: date))"
|
|
}
|
|
|
|
private var isPast: Bool {
|
|
calendar.startOfDay(for: date) < calendar.startOfDay(for: Date())
|
|
}
|
|
|
|
var body: some View {
|
|
Button(action: onTap) {
|
|
ZStack {
|
|
// Range highlight background (stretches edge to edge)
|
|
if isInRange || isStart || isEnd {
|
|
HStack(spacing: 0) {
|
|
Rectangle()
|
|
.fill(Theme.warmOrange.opacity(0.15))
|
|
.frame(maxWidth: .infinity)
|
|
.opacity(isStart && !isEnd ? 0 : 1)
|
|
.offset(x: isStart ? 20 : 0)
|
|
|
|
Rectangle()
|
|
.fill(Theme.warmOrange.opacity(0.15))
|
|
.frame(maxWidth: .infinity)
|
|
.opacity(isEnd && !isStart ? 0 : 1)
|
|
.offset(x: isEnd ? -20 : 0)
|
|
}
|
|
.opacity(isStart && isEnd ? 0 : 1) // Hide when start == end
|
|
}
|
|
|
|
// Day circle
|
|
ZStack {
|
|
if isStart || isEnd {
|
|
Circle()
|
|
.fill(Theme.warmOrange)
|
|
} else if isToday {
|
|
Circle()
|
|
.stroke(Theme.warmOrange, lineWidth: 2)
|
|
}
|
|
|
|
Text(dayNumber)
|
|
.font(.system(size: 14, weight: (isStart || isEnd) ? .bold : .medium))
|
|
.foregroundStyle(
|
|
isPast ? Theme.textMuted(colorScheme).opacity(0.5) :
|
|
(isStart || isEnd) ? .white :
|
|
isToday ? Theme.warmOrange :
|
|
Theme.textPrimary(colorScheme)
|
|
)
|
|
}
|
|
.frame(width: 36, height: 36)
|
|
}
|
|
}
|
|
.buttonStyle(.plain)
|
|
.disabled(isPast)
|
|
.frame(height: 40)
|
|
}
|
|
}
|
|
|
|
struct SportSelectionChip: View {
|
|
let sport: Sport
|
|
let isSelected: Bool
|
|
let onTap: () -> Void
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
|
|
var body: some View {
|
|
Button(action: onTap) {
|
|
VStack(spacing: Theme.Spacing.xs) {
|
|
ZStack {
|
|
Circle()
|
|
.fill(isSelected ? sport.themeColor : sport.themeColor.opacity(0.15))
|
|
.frame(width: 44, height: 44)
|
|
|
|
Image(systemName: sport.iconName)
|
|
.font(.title3)
|
|
.foregroundStyle(isSelected ? .white : sport.themeColor)
|
|
}
|
|
|
|
Text(sport.rawValue)
|
|
.font(.system(size: Theme.FontSize.micro, weight: .medium))
|
|
.foregroundStyle(isSelected ? Theme.textPrimary(colorScheme) : Theme.textSecondary(colorScheme))
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
TripCreationView(viewModel: TripCreationViewModel())
|
|
}
|