- Lock all maps to North America (no pan/zoom) in ProgressMapView and TripDetailView - Sort saved trips by most cities (stops count) - Filter cross-country trips to top 2 by stops on home screen - Use LocationSearchSheet for Follow Team home location (consistent with must-stop) - Initialize DateRangePicker to show selected dates on appear Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2513 lines
90 KiB
Swift
2513 lines
90 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
|
|
case homeLocation
|
|
}
|
|
|
|
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
|
|
|
|
case .locations:
|
|
// Locations + Sports + optional games
|
|
locationSection
|
|
sportsSection
|
|
datesSection
|
|
gamesSection
|
|
|
|
case .followTeam:
|
|
// Team picker + Dates + Home location toggle
|
|
teamPickerSection
|
|
datesSection
|
|
homeLocationSection
|
|
}
|
|
|
|
// Common sections
|
|
travelSection
|
|
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)
|
|
case .homeLocation:
|
|
viewModel.startLocationText = location.name
|
|
viewModel.startLocation = location
|
|
}
|
|
}
|
|
}
|
|
.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(.largeTitle)
|
|
.foregroundStyle(Theme.warmOrange)
|
|
|
|
Text("Plan Your Adventure")
|
|
.font(.title2)
|
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
|
|
|
Text("Select your games, set your route, and hit the road")
|
|
.font(.subheadline)
|
|
.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?") {
|
|
LazyVGrid(
|
|
columns: [
|
|
GridItem(.flexible(), spacing: Theme.Spacing.sm),
|
|
GridItem(.flexible(), spacing: Theme.Spacing.sm)
|
|
],
|
|
spacing: Theme.Spacing.sm
|
|
) {
|
|
ForEach(PlanningMode.allCases) { mode in
|
|
PlanningModeCard(
|
|
mode: mode,
|
|
isSelected: viewModel.planningMode == mode,
|
|
colorScheme: colorScheme
|
|
) {
|
|
withAnimation(.easeInOut(duration: 0.2)) {
|
|
viewModel.planningMode = mode
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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(.subheadline)
|
|
.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(.subheadline)
|
|
.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(.subheadline)
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(result.name)
|
|
.font(.subheadline)
|
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
|
|
|
if !result.address.isEmpty {
|
|
Text(result.address)
|
|
.font(.caption)
|
|
.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(.body)
|
|
.foregroundStyle(Theme.textSecondary(colorScheme))
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .center)
|
|
.padding(.vertical, Theme.Spacing.md)
|
|
.task(id: viewModel.selectedSports) {
|
|
// Always load 90-day browsing window for gameFirst mode
|
|
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(.body)
|
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
|
Text("\(viewModel.availableGames.count) games available")
|
|
.font(.subheadline)
|
|
.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(.body)
|
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
|
|
|
Spacer()
|
|
|
|
Button {
|
|
viewModel.deselectAllGames()
|
|
} label: {
|
|
Text("Deselect All")
|
|
.font(.subheadline)
|
|
.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(.subheadline)
|
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
|
Spacer()
|
|
Text(game.game.formattedDate)
|
|
.font(.caption)
|
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
|
}
|
|
}
|
|
|
|
if viewModel.selectedGames.count > 3 {
|
|
Text("+ \(viewModel.selectedGames.count - 3) more")
|
|
.font(.subheadline)
|
|
.foregroundStyle(Theme.textSecondary(colorScheme))
|
|
}
|
|
}
|
|
.padding(Theme.Spacing.md)
|
|
.background(Theme.cardBackgroundElevated(colorScheme))
|
|
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
|
|
}
|
|
}
|
|
}
|
|
|
|
private var sportsSection: some View {
|
|
ThemedSection(title: "Sports") {
|
|
SportSelectorGrid(
|
|
selectedSports: viewModel.selectedSports
|
|
) { sport in
|
|
if viewModel.selectedSports.contains(sport) {
|
|
viewModel.selectedSports.remove(sport)
|
|
} else {
|
|
viewModel.selectedSports.insert(sport)
|
|
}
|
|
}
|
|
.padding(.vertical, Theme.Spacing.xs)
|
|
}
|
|
}
|
|
|
|
private var datesSection: some View {
|
|
ThemedSection(title: "Dates") {
|
|
DateRangePicker(
|
|
startDate: $viewModel.startDate,
|
|
endDate: $viewModel.endDate
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Follow Team Mode
|
|
|
|
@State private var showTeamPicker = false
|
|
|
|
private var teamPickerSection: some View {
|
|
ThemedSection(title: "Select Team") {
|
|
Button {
|
|
showTeamPicker = true
|
|
} label: {
|
|
HStack(spacing: Theme.Spacing.md) {
|
|
ZStack {
|
|
Circle()
|
|
.fill(Theme.warmOrange.opacity(0.15))
|
|
.frame(width: 40, height: 40)
|
|
Image(systemName: "person.3.fill")
|
|
.foregroundStyle(Theme.warmOrange)
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
if let team = viewModel.followedTeam {
|
|
Text(team.name)
|
|
.font(.body)
|
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
|
Text(team.sport.displayName)
|
|
.font(.caption)
|
|
.foregroundStyle(Theme.textSecondary(colorScheme))
|
|
} else {
|
|
Text("Choose a team")
|
|
.font(.body)
|
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
|
Text("Pick the team to follow")
|
|
.font(.caption)
|
|
.foregroundStyle(Theme.textMuted(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)
|
|
}
|
|
.sheet(isPresented: $showTeamPicker) {
|
|
TeamPickerSheet(
|
|
selectedTeamId: $viewModel.followTeamId,
|
|
teamsBySport: viewModel.teamsBySport
|
|
)
|
|
}
|
|
}
|
|
|
|
private var homeLocationSection: some View {
|
|
ThemedSection(title: "Trip Start/End") {
|
|
VStack(spacing: Theme.Spacing.md) {
|
|
ThemedToggle(
|
|
label: "Start and end from home",
|
|
isOn: $viewModel.useHomeLocation,
|
|
icon: "house.fill"
|
|
)
|
|
|
|
if viewModel.useHomeLocation {
|
|
// Show button to open location search sheet (same as must-stop)
|
|
Button {
|
|
cityInputType = .homeLocation
|
|
showCityInput = true
|
|
} label: {
|
|
HStack(spacing: Theme.Spacing.md) {
|
|
ZStack {
|
|
Circle()
|
|
.fill(Theme.warmOrange.opacity(0.15))
|
|
.frame(width: 40, height: 40)
|
|
Image(systemName: "house.fill")
|
|
.foregroundStyle(Theme.warmOrange)
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
if let location = viewModel.startLocation {
|
|
Text(location.name)
|
|
.font(.body)
|
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
|
if let address = location.address, !address.isEmpty {
|
|
Text(address)
|
|
.font(.caption)
|
|
.foregroundStyle(Theme.textSecondary(colorScheme))
|
|
}
|
|
} else {
|
|
Text("Choose home location")
|
|
.font(.body)
|
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
|
Text("Tap to search cities")
|
|
.font(.caption)
|
|
.foregroundStyle(Theme.textMuted(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)
|
|
} else {
|
|
Text("Trip will start at first game and end at last game (fly-in/fly-out)")
|
|
.font(.caption)
|
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
|
.padding(.leading, 32)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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(.body)
|
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
|
|
|
Spacer()
|
|
|
|
Text("\(viewModel.selectedGamesCount) selected")
|
|
.font(.subheadline)
|
|
.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) {
|
|
// Region selector
|
|
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
|
|
Text("Regions")
|
|
.font(.subheadline)
|
|
.foregroundStyle(Theme.textSecondary(colorScheme))
|
|
|
|
RegionMapSelector(
|
|
selectedRegions: $viewModel.selectedRegions,
|
|
onToggle: { region in
|
|
viewModel.toggleRegion(region)
|
|
}
|
|
)
|
|
|
|
if viewModel.selectedRegions.isEmpty {
|
|
Text("Select at least one region")
|
|
.font(.caption)
|
|
.foregroundStyle(Theme.warmOrange)
|
|
.padding(.top, Theme.Spacing.xxs)
|
|
} else {
|
|
Text("Games will be found in selected regions")
|
|
.font(.caption)
|
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
|
.padding(.top, Theme.Spacing.xxs)
|
|
}
|
|
}
|
|
|
|
// Route preference
|
|
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
|
|
Text("Route Preference")
|
|
.font(.subheadline)
|
|
.foregroundStyle(Theme.textSecondary(colorScheme))
|
|
|
|
Picker("Route Preference", selection: $viewModel.routePreference) {
|
|
ForEach(RoutePreference.allCases) { pref in
|
|
Text(pref.displayName).tag(pref)
|
|
}
|
|
}
|
|
.pickerStyle(.segmented)
|
|
}
|
|
|
|
// Allow repeat cities
|
|
ThemedToggle(
|
|
label: "Allow Repeat Cities",
|
|
isOn: $viewModel.allowRepeatCities,
|
|
icon: "arrow.triangle.2.circlepath"
|
|
)
|
|
|
|
if !viewModel.allowRepeatCities {
|
|
Text("Each city will only be visited on one day")
|
|
.font(.caption)
|
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
|
.padding(.leading, 32)
|
|
}
|
|
}
|
|
.animation(.easeInOut(duration: 0.2), value: viewModel.selectedRegions)
|
|
}
|
|
}
|
|
|
|
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(.body)
|
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
|
Spacer()
|
|
Text("\(viewModel.mustStopLocations.count)")
|
|
.font(.subheadline)
|
|
.foregroundStyle(Theme.textSecondary(colorScheme))
|
|
}
|
|
|
|
ForEach(viewModel.mustStopLocations, id: \.name) { location in
|
|
HStack {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(location.name)
|
|
.font(.subheadline)
|
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
|
if let address = location.address, !address.isEmpty {
|
|
Text(address)
|
|
.font(.caption)
|
|
.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(.subheadline)
|
|
.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(.subheadline)
|
|
.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 (Calendar view: Sport → Team → Date)
|
|
|
|
struct GamePickerSheet: View {
|
|
let games: [RichGame]
|
|
@Binding var selectedIds: Set<UUID>
|
|
@Environment(\.dismiss) private var dismiss
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
|
|
@State private var expandedSports: Set<Sport> = []
|
|
@State private var expandedTeams: Set<UUID> = []
|
|
|
|
// Group games by Sport → Team (home team only to avoid duplicates)
|
|
private var gamesBySport: [Sport: [TeamWithGames]] {
|
|
var result: [Sport: [UUID: TeamWithGames]] = [:]
|
|
|
|
for game in games {
|
|
let sport = game.game.sport
|
|
let team = game.homeTeam
|
|
|
|
if result[sport] == nil {
|
|
result[sport] = [:]
|
|
}
|
|
|
|
if var teamData = result[sport]?[team.id] {
|
|
teamData.games.append(game)
|
|
result[sport]?[team.id] = teamData
|
|
} else {
|
|
result[sport]?[team.id] = TeamWithGames(
|
|
team: team,
|
|
sport: sport,
|
|
games: [game]
|
|
)
|
|
}
|
|
}
|
|
|
|
// Convert to sorted arrays
|
|
var sortedResult: [Sport: [TeamWithGames]] = [:]
|
|
for (sport, teamsDict) in result {
|
|
sortedResult[sport] = teamsDict.values.sorted { $0.team.name < $1.team.name }
|
|
}
|
|
return sortedResult
|
|
}
|
|
|
|
private var sortedSports: [Sport] {
|
|
Sport.supported.filter { gamesBySport[$0] != nil }
|
|
}
|
|
|
|
private var selectedGamesCount: Int {
|
|
selectedIds.count
|
|
}
|
|
|
|
private func selectedCountForSport(_ sport: Sport) -> Int {
|
|
guard let teams = gamesBySport[sport] else { return 0 }
|
|
return teams.flatMap { $0.games }.filter { selectedIds.contains($0.id) }.count
|
|
}
|
|
|
|
private func selectedCountForTeam(_ teamData: TeamWithGames) -> Int {
|
|
teamData.games.filter { selectedIds.contains($0.id) }.count
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
ScrollView {
|
|
LazyVStack(spacing: 0) {
|
|
// Selected games summary
|
|
if !selectedIds.isEmpty {
|
|
HStack {
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.foregroundStyle(.green)
|
|
Text("\(selectedGamesCount) game(s) selected")
|
|
.font(.subheadline)
|
|
Spacer()
|
|
}
|
|
.padding(Theme.Spacing.md)
|
|
.background(Theme.cardBackground(colorScheme))
|
|
}
|
|
|
|
// Sport sections
|
|
ForEach(sortedSports) { sport in
|
|
SportSection(
|
|
sport: sport,
|
|
teams: gamesBySport[sport] ?? [],
|
|
selectedIds: $selectedIds,
|
|
expandedSports: $expandedSports,
|
|
expandedTeams: $expandedTeams,
|
|
selectedCount: selectedCountForSport(sport)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
.themedBackground()
|
|
.navigationTitle("Select Games")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
if !selectedIds.isEmpty {
|
|
Button("Reset") {
|
|
selectedIds.removeAll()
|
|
}
|
|
.foregroundStyle(.red)
|
|
}
|
|
}
|
|
ToolbarItem(placement: .confirmationAction) {
|
|
Button("Done") {
|
|
dismiss()
|
|
}
|
|
.fontWeight(.semibold)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Sport Section
|
|
|
|
struct SportSection: View {
|
|
let sport: Sport
|
|
let teams: [TeamWithGames]
|
|
@Binding var selectedIds: Set<UUID>
|
|
@Binding var expandedSports: Set<Sport>
|
|
@Binding var expandedTeams: Set<UUID>
|
|
let selectedCount: Int
|
|
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
|
|
private var isExpanded: Bool {
|
|
expandedSports.contains(sport)
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
// Sport header
|
|
Button {
|
|
withAnimation(.easeInOut(duration: 0.2)) {
|
|
if isExpanded {
|
|
expandedSports.remove(sport)
|
|
} else {
|
|
expandedSports.insert(sport)
|
|
}
|
|
}
|
|
} label: {
|
|
HStack(spacing: Theme.Spacing.sm) {
|
|
Image(systemName: sport.iconName)
|
|
.font(.title3)
|
|
.foregroundStyle(sport.themeColor)
|
|
.frame(width: 32)
|
|
|
|
Text(sport.rawValue)
|
|
.font(.headline)
|
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
|
|
|
Text("\(teams.flatMap { $0.games }.count) games")
|
|
.font(.subheadline)
|
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
|
|
|
Spacer()
|
|
|
|
if selectedCount > 0 {
|
|
Text("\(selectedCount)")
|
|
.font(.caption)
|
|
.foregroundStyle(.white)
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 4)
|
|
.background(sport.themeColor)
|
|
.clipShape(Capsule())
|
|
}
|
|
|
|
Image(systemName: "chevron.right")
|
|
.font(.system(size: 14, weight: .semibold))
|
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
|
.rotationEffect(.degrees(isExpanded ? 90 : 0))
|
|
}
|
|
.padding(Theme.Spacing.md)
|
|
.background(Theme.cardBackground(colorScheme))
|
|
}
|
|
.buttonStyle(.plain)
|
|
|
|
// Teams list (when expanded)
|
|
if isExpanded {
|
|
VStack(spacing: 0) {
|
|
ForEach(teams) { teamData in
|
|
TeamSection(
|
|
teamData: teamData,
|
|
selectedIds: $selectedIds,
|
|
expandedTeams: $expandedTeams
|
|
)
|
|
}
|
|
}
|
|
.padding(.leading, Theme.Spacing.lg)
|
|
}
|
|
|
|
Divider()
|
|
.overlay(Theme.surfaceGlow(colorScheme))
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Team Section
|
|
|
|
struct TeamSection: View {
|
|
let teamData: TeamWithGames
|
|
@Binding var selectedIds: Set<UUID>
|
|
@Binding var expandedTeams: Set<UUID>
|
|
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
|
|
private var isExpanded: Bool {
|
|
expandedTeams.contains(teamData.id)
|
|
}
|
|
|
|
private var selectedCount: Int {
|
|
teamData.games.filter { selectedIds.contains($0.id) }.count
|
|
}
|
|
|
|
// Group games by date
|
|
private var gamesByDate: [(date: String, games: [RichGame])] {
|
|
let grouped = Dictionary(grouping: teamData.sortedGames) { game in
|
|
game.game.formattedDate
|
|
}
|
|
return grouped.sorted { $0.value.first!.game.dateTime < $1.value.first!.game.dateTime }
|
|
.map { (date: $0.key, games: $0.value) }
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
// Team header
|
|
Button {
|
|
withAnimation(.easeInOut(duration: 0.2)) {
|
|
if isExpanded {
|
|
expandedTeams.remove(teamData.id)
|
|
} else {
|
|
expandedTeams.insert(teamData.id)
|
|
}
|
|
}
|
|
} label: {
|
|
HStack(spacing: Theme.Spacing.sm) {
|
|
// Team color
|
|
if let colorHex = teamData.team.primaryColor {
|
|
Circle()
|
|
.fill(Color(hex: colorHex))
|
|
.frame(width: 10, height: 10)
|
|
}
|
|
|
|
Text("\(teamData.team.city) \(teamData.team.name)")
|
|
.font(.subheadline)
|
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
|
|
|
Text("\(teamData.games.count)")
|
|
.font(.caption)
|
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
|
|
|
Spacer()
|
|
|
|
if selectedCount > 0 {
|
|
Text("\(selectedCount)")
|
|
.font(.caption2)
|
|
.foregroundStyle(.white)
|
|
.padding(.horizontal, 6)
|
|
.padding(.vertical, 3)
|
|
.background(Theme.warmOrange)
|
|
.clipShape(Capsule())
|
|
}
|
|
|
|
Image(systemName: "chevron.right")
|
|
.font(.caption)
|
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
|
.rotationEffect(.degrees(isExpanded ? 90 : 0))
|
|
}
|
|
.padding(.vertical, Theme.Spacing.sm)
|
|
.padding(.horizontal, Theme.Spacing.md)
|
|
.background(Theme.cardBackgroundElevated(colorScheme))
|
|
}
|
|
.buttonStyle(.plain)
|
|
|
|
// Games grouped by date (when expanded)
|
|
if isExpanded {
|
|
VStack(spacing: 0) {
|
|
ForEach(gamesByDate, id: \.date) { dateGroup in
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
// Date header
|
|
Text(dateGroup.date)
|
|
.font(.caption)
|
|
.foregroundStyle(Theme.warmOrange)
|
|
.padding(.horizontal, Theme.Spacing.md)
|
|
.padding(.top, Theme.Spacing.sm)
|
|
.padding(.bottom, Theme.Spacing.xs)
|
|
|
|
// Games on this date
|
|
ForEach(dateGroup.games) { game in
|
|
GameCalendarRow(
|
|
game: game,
|
|
isSelected: selectedIds.contains(game.id),
|
|
onTap: {
|
|
if selectedIds.contains(game.id) {
|
|
selectedIds.remove(game.id)
|
|
} else {
|
|
selectedIds.insert(game.id)
|
|
}
|
|
}
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(.leading, Theme.Spacing.md)
|
|
.background(Theme.cardBackgroundElevated(colorScheme).opacity(0.5))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Game Calendar Row
|
|
|
|
struct GameCalendarRow: View {
|
|
let game: RichGame
|
|
let isSelected: Bool
|
|
let onTap: () -> Void
|
|
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
|
|
var body: some View {
|
|
Button(action: onTap) {
|
|
HStack(spacing: Theme.Spacing.sm) {
|
|
// Selection indicator
|
|
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
|
|
.font(.title3)
|
|
.foregroundStyle(isSelected ? Theme.warmOrange : Theme.textMuted(colorScheme))
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text("vs \(game.awayTeam.name)")
|
|
.font(.subheadline)
|
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
|
|
|
HStack(spacing: Theme.Spacing.xs) {
|
|
Text(game.game.gameTime)
|
|
.font(.caption)
|
|
.foregroundStyle(Theme.textSecondary(colorScheme))
|
|
|
|
Text("•")
|
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
|
|
|
Text(game.stadium.name)
|
|
.font(.caption)
|
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
}
|
|
.padding(.vertical, Theme.Spacing.sm)
|
|
.padding(.horizontal, Theme.Spacing.md)
|
|
.background(isSelected ? Theme.warmOrange.opacity(0.1) : Color.clear)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
|
|
// 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: - 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"
|
|
}
|
|
}
|
|
}
|
|
|
|
enum TripPaceFilter: String, CaseIterable, Identifiable {
|
|
case all = "All"
|
|
case packed = "Packed"
|
|
case moderate = "Moderate"
|
|
case relaxed = "Relaxed"
|
|
|
|
var id: String { rawValue }
|
|
|
|
var icon: String {
|
|
switch self {
|
|
case .all: return "rectangle.stack"
|
|
case .packed: return "flame"
|
|
case .moderate: return "equal.circle"
|
|
case .relaxed: return "leaf"
|
|
}
|
|
}
|
|
}
|
|
|
|
enum CitiesFilter: Int, CaseIterable, Identifiable {
|
|
case noLimit = 100
|
|
case fifteen = 15
|
|
case ten = 10
|
|
case five = 5
|
|
case four = 4
|
|
case three = 3
|
|
case two = 2
|
|
|
|
var id: Int { rawValue }
|
|
|
|
var displayName: String {
|
|
switch self {
|
|
case .noLimit: return "No Limit"
|
|
case .fifteen: return "15"
|
|
case .ten: return "10"
|
|
case .five: return "5"
|
|
case .four: return "4"
|
|
case .three: return "3"
|
|
case .two: return "2"
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
@State private var citiesFilter: CitiesFilter = .noLimit
|
|
@State private var paceFilter: TripPaceFilter = .all
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
|
|
// MARK: - Computed Properties
|
|
|
|
private func uniqueCityCount(for option: ItineraryOption) -> Int {
|
|
Set(option.stops.map { $0.city }).count
|
|
}
|
|
|
|
private var filteredAndSortedOptions: [ItineraryOption] {
|
|
// Apply filters first
|
|
let filtered = options.filter { option in
|
|
let cityCount = uniqueCityCount(for: option)
|
|
|
|
// City filter
|
|
guard cityCount <= citiesFilter.rawValue else { return false }
|
|
|
|
// Pace filter based on games per day ratio
|
|
switch paceFilter {
|
|
case .all:
|
|
return true
|
|
case .packed:
|
|
// High game density: > 0.8 games per day
|
|
return gamesPerDay(for: option) >= 0.8
|
|
case .moderate:
|
|
// Medium density: 0.4-0.8 games per day
|
|
let gpd = gamesPerDay(for: option)
|
|
return gpd >= 0.4 && gpd < 0.8
|
|
case .relaxed:
|
|
// Low density: < 0.4 games per day
|
|
return gamesPerDay(for: option) < 0.4
|
|
}
|
|
}
|
|
|
|
// Then apply sorting
|
|
switch sortOption {
|
|
case .recommended:
|
|
return filtered
|
|
case .mostGames:
|
|
return filtered.sorted { $0.totalGames > $1.totalGames }
|
|
case .leastGames:
|
|
return filtered.sorted { $0.totalGames < $1.totalGames }
|
|
case .mostMiles:
|
|
return filtered.sorted { $0.totalDistanceMiles > $1.totalDistanceMiles }
|
|
case .leastMiles:
|
|
return filtered.sorted { $0.totalDistanceMiles < $1.totalDistanceMiles }
|
|
case .bestEfficiency:
|
|
return filtered.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
|
|
}
|
|
}
|
|
}
|
|
|
|
private func gamesPerDay(for option: ItineraryOption) -> Double {
|
|
guard let first = option.stops.first,
|
|
let last = option.stops.last else { return 0 }
|
|
let days = max(1, Calendar.current.dateComponents([.day], from: first.arrivalDate, to: last.departureDate).day ?? 1)
|
|
return Double(option.totalGames) / Double(days)
|
|
}
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
LazyVStack(spacing: 16) {
|
|
// Hero header
|
|
VStack(spacing: 8) {
|
|
Image(systemName: "point.topright.arrow.triangle.backward.to.point.bottomleft.scurvepath.fill")
|
|
.font(.largeTitle)
|
|
.foregroundStyle(Theme.warmOrange)
|
|
|
|
Text("\(filteredAndSortedOptions.count) of \(options.count) Routes")
|
|
.font(.title2)
|
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
|
}
|
|
.padding(.top, Theme.Spacing.lg)
|
|
|
|
// Filters section
|
|
filtersSection
|
|
.padding(.horizontal, Theme.Spacing.md)
|
|
|
|
// Options list
|
|
if filteredAndSortedOptions.isEmpty {
|
|
emptyFilterState
|
|
.padding(.top, Theme.Spacing.xl)
|
|
} else {
|
|
ForEach(filteredAndSortedOptions) { 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(.subheadline)
|
|
Text(sortOption.rawValue)
|
|
.font(.subheadline)
|
|
Image(systemName: "chevron.down")
|
|
.font(.caption)
|
|
}
|
|
.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: - Filters Section
|
|
|
|
private var filtersSection: some View {
|
|
VStack(spacing: Theme.Spacing.md) {
|
|
// Sort and Pace row
|
|
HStack(spacing: Theme.Spacing.sm) {
|
|
sortPicker
|
|
Spacer()
|
|
pacePicker
|
|
}
|
|
|
|
// Cities picker
|
|
citiesPicker
|
|
}
|
|
.padding(Theme.Spacing.md)
|
|
.background(Theme.cardBackground(colorScheme))
|
|
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
|
|
}
|
|
|
|
private var pacePicker: some View {
|
|
Menu {
|
|
ForEach(TripPaceFilter.allCases) { pace in
|
|
Button {
|
|
withAnimation(.easeInOut(duration: 0.2)) {
|
|
paceFilter = pace
|
|
}
|
|
} label: {
|
|
Label(pace.rawValue, systemImage: pace.icon)
|
|
}
|
|
}
|
|
} label: {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: paceFilter.icon)
|
|
.font(.caption)
|
|
Text(paceFilter.rawValue)
|
|
.font(.subheadline)
|
|
Image(systemName: "chevron.down")
|
|
.font(.caption2)
|
|
}
|
|
.foregroundStyle(paceFilter == .all ? Theme.textPrimary(colorScheme) : Theme.warmOrange)
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 8)
|
|
.background(paceFilter == .all ? Theme.cardBackground(colorScheme) : Theme.warmOrange.opacity(0.15))
|
|
.clipShape(Capsule())
|
|
.overlay(
|
|
Capsule()
|
|
.strokeBorder(paceFilter == .all ? Theme.textMuted(colorScheme).opacity(0.2) : Theme.warmOrange.opacity(0.3), lineWidth: 1)
|
|
)
|
|
}
|
|
}
|
|
|
|
private var citiesPicker: some View {
|
|
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
|
|
Label("Max Cities", systemImage: "mappin.circle")
|
|
.font(.subheadline)
|
|
.foregroundStyle(Theme.textSecondary(colorScheme))
|
|
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 8) {
|
|
ForEach(CitiesFilter.allCases) { filter in
|
|
Button {
|
|
withAnimation(.easeInOut(duration: 0.2)) {
|
|
citiesFilter = filter
|
|
}
|
|
} label: {
|
|
Text(filter.displayName)
|
|
.font(.system(size: 13, weight: citiesFilter == filter ? .semibold : .medium))
|
|
.foregroundStyle(citiesFilter == filter ? .white : Theme.textPrimary(colorScheme))
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 6)
|
|
.background(citiesFilter == filter ? Theme.warmOrange : Theme.cardBackground(colorScheme))
|
|
.clipShape(Capsule())
|
|
.overlay(
|
|
Capsule()
|
|
.strokeBorder(citiesFilter == filter ? Color.clear : Theme.textMuted(colorScheme).opacity(0.2), lineWidth: 1)
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var emptyFilterState: some View {
|
|
VStack(spacing: Theme.Spacing.md) {
|
|
Image(systemName: "line.3.horizontal.decrease.circle")
|
|
.font(.system(size: 48))
|
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
|
|
|
Text("No routes match your filters")
|
|
.font(.body)
|
|
.foregroundStyle(Theme.textSecondary(colorScheme))
|
|
|
|
Button {
|
|
withAnimation {
|
|
citiesFilter = .noLimit
|
|
paceFilter = .all
|
|
}
|
|
} label: {
|
|
Text("Reset Filters")
|
|
.font(.subheadline)
|
|
.foregroundStyle(Theme.warmOrange)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, Theme.Spacing.xxl)
|
|
}
|
|
}
|
|
|
|
// 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(.subheadline)
|
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
|
|
|
VStack(spacing: 0) {
|
|
Text("|")
|
|
.font(.caption2)
|
|
Image(systemName: "chevron.down")
|
|
.font(.caption2)
|
|
}
|
|
.foregroundStyle(Theme.warmOrange)
|
|
|
|
Text(uniqueCities.last ?? "")
|
|
.font(.subheadline)
|
|
.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(.caption)
|
|
.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(.caption2)
|
|
Text("\(item.sport.rawValue.uppercased()) \(item.count)")
|
|
.font(.caption2)
|
|
}
|
|
.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(.caption2)
|
|
.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(.title2)
|
|
.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(.subheadline)
|
|
.foregroundStyle(Theme.textSecondary(colorScheme))
|
|
|
|
HStack(spacing: Theme.Spacing.sm) {
|
|
Image(systemName: icon)
|
|
.foregroundStyle(Theme.warmOrange)
|
|
.frame(width: 24)
|
|
|
|
TextField(placeholder, text: $text)
|
|
.font(.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(.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(.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(.body)
|
|
.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(.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
|
|
}
|
|
.onAppear {
|
|
// Initialize displayed month to show the start date's month
|
|
displayedMonth = calendar.startOfDay(for: startDate)
|
|
// If dates are already selected (endDate > startDate), show complete state
|
|
if endDate > startDate {
|
|
selectionState = .complete
|
|
}
|
|
}
|
|
}
|
|
|
|
private var selectedRangeSummary: some View {
|
|
HStack(spacing: Theme.Spacing.md) {
|
|
// Start date
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text("START")
|
|
.font(.caption2)
|
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
|
Text(startDate.formatted(.dateTime.month(.abbreviated).day().year()))
|
|
.font(.body)
|
|
.foregroundStyle(Theme.warmOrange)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
// Arrow
|
|
Image(systemName: "arrow.right")
|
|
.font(.subheadline)
|
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
|
|
|
// End date
|
|
VStack(alignment: .trailing, spacing: 4) {
|
|
Text("END")
|
|
.font(.caption2)
|
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
|
Text(endDate.formatted(.dateTime.month(.abbreviated).day().year()))
|
|
.font(.body)
|
|
.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(.body)
|
|
.foregroundStyle(Theme.warmOrange)
|
|
.frame(width: 36, height: 36)
|
|
.background(Theme.warmOrange.opacity(0.15))
|
|
.clipShape(Circle())
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Text(monthYearString)
|
|
.font(.headline)
|
|
.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(.body)
|
|
.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(.caption)
|
|
.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(.subheadline)
|
|
.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)
|
|
}
|
|
}
|
|
|
|
// MARK: - Team Picker Sheet
|
|
|
|
struct TeamPickerSheet: View {
|
|
@Binding var selectedTeamId: UUID?
|
|
let teamsBySport: [Sport: [Team]]
|
|
|
|
@Environment(\.dismiss) private var dismiss
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
|
|
@State private var searchText = ""
|
|
|
|
private var sortedSports: [Sport] {
|
|
Sport.allCases.filter { teamsBySport[$0] != nil && !teamsBySport[$0]!.isEmpty }
|
|
}
|
|
|
|
private var filteredTeamsBySport: [Sport: [Team]] {
|
|
guard !searchText.isEmpty else { return teamsBySport }
|
|
|
|
var filtered: [Sport: [Team]] = [:]
|
|
for (sport, teams) in teamsBySport {
|
|
let matchingTeams = teams.filter {
|
|
$0.name.localizedCaseInsensitiveContains(searchText) ||
|
|
$0.city.localizedCaseInsensitiveContains(searchText) ||
|
|
$0.abbreviation.localizedCaseInsensitiveContains(searchText)
|
|
}
|
|
if !matchingTeams.isEmpty {
|
|
filtered[sport] = matchingTeams
|
|
}
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
List {
|
|
ForEach(sortedSports, id: \.self) { sport in
|
|
if let teams = filteredTeamsBySport[sport], !teams.isEmpty {
|
|
Section(sport.displayName) {
|
|
ForEach(teams) { team in
|
|
TeamRow(
|
|
team: team,
|
|
isSelected: selectedTeamId == team.id,
|
|
colorScheme: colorScheme
|
|
) {
|
|
selectedTeamId = team.id
|
|
dismiss()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.searchable(text: $searchText, prompt: "Search teams")
|
|
.navigationTitle("Select Team")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button("Cancel") {
|
|
dismiss()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Planning Mode Card
|
|
|
|
struct PlanningModeCard: View {
|
|
let mode: PlanningMode
|
|
let isSelected: Bool
|
|
let colorScheme: ColorScheme
|
|
let onTap: () -> Void
|
|
|
|
var body: some View {
|
|
Button(action: onTap) {
|
|
VStack(spacing: Theme.Spacing.sm) {
|
|
// Icon
|
|
ZStack {
|
|
Circle()
|
|
.fill(isSelected ? Theme.warmOrange : Theme.warmOrange.opacity(0.15))
|
|
.frame(width: 44, height: 44)
|
|
|
|
Image(systemName: mode.iconName)
|
|
.font(.system(size: 20, weight: .semibold))
|
|
.foregroundStyle(isSelected ? .white : Theme.warmOrange)
|
|
}
|
|
|
|
// Title
|
|
Text(mode.displayName)
|
|
.font(.subheadline.weight(.medium))
|
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
|
.lineLimit(1)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, Theme.Spacing.md)
|
|
.padding(.horizontal, Theme.Spacing.sm)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
|
|
.fill(Theme.cardBackgroundElevated(colorScheme))
|
|
)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
|
|
.strokeBorder(
|
|
isSelected ? Theme.warmOrange : Color.clear,
|
|
lineWidth: 2
|
|
)
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel("\(mode.displayName): \(mode.description)")
|
|
.accessibilityAddTraits(isSelected ? .isSelected : [])
|
|
}
|
|
}
|
|
|
|
struct TeamRow: View {
|
|
let team: Team
|
|
let isSelected: Bool
|
|
let colorScheme: ColorScheme
|
|
let onTap: () -> Void
|
|
|
|
var body: some View {
|
|
Button(action: onTap) {
|
|
HStack(spacing: Theme.Spacing.md) {
|
|
// Team color indicator
|
|
if let colorHex = team.primaryColor {
|
|
Circle()
|
|
.fill(Color(hex: colorHex))
|
|
.frame(width: 12, height: 12)
|
|
} else {
|
|
Circle()
|
|
.fill(Theme.warmOrange)
|
|
.frame(width: 12, height: 12)
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(team.name)
|
|
.font(.body)
|
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
|
Text(team.city)
|
|
.font(.caption)
|
|
.foregroundStyle(Theme.textSecondary(colorScheme))
|
|
}
|
|
|
|
Spacer()
|
|
|
|
if isSelected {
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.foregroundStyle(Theme.warmOrange)
|
|
}
|
|
}
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
TripCreationView(viewModel: TripCreationViewModel())
|
|
}
|