feat(wizard): add mode-specific trip wizard inputs
- Add GamePickerStep with sheet-based Sport → Team → Game selection - Add TeamPickerStep with sheet-based Sport → Team selection - Add LocationsStep for start/end location selection with round trip toggle - Update TripWizardViewModel with mode-specific fields and validation - Update TripWizardView with conditional step rendering per mode - Update ReviewStep with mode-aware validation display - Fix gameFirst mode to derive date range from selected games Each planning mode now shows only relevant steps: - By Dates: Dates → Sports → Regions → Route → Repeat → Must Stops - By Games: Game Picker → Route → Repeat → Must Stops - By Route: Locations → Dates → Sports → Route → Repeat → Must Stops - Follow Team: Team Picker → Dates → Route → Repeat → Must Stops Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -49,6 +49,22 @@ final class TripWizardViewModel {
|
|||||||
|
|
||||||
var mustStopLocations: [LocationInput] = []
|
var mustStopLocations: [LocationInput] = []
|
||||||
|
|
||||||
|
// MARK: - Mode-Specific: gameFirst (cascading selection)
|
||||||
|
|
||||||
|
var gamePickerSports: Set<Sport> = []
|
||||||
|
var gamePickerTeamIds: Set<String> = []
|
||||||
|
var selectedGameIds: Set<String> = []
|
||||||
|
|
||||||
|
// MARK: - Mode-Specific: followTeam
|
||||||
|
|
||||||
|
var selectedTeamId: String? = nil
|
||||||
|
var teamPickerSport: Sport? = nil
|
||||||
|
|
||||||
|
// MARK: - Mode-Specific: locations
|
||||||
|
|
||||||
|
var startLocation: LocationInput? = nil
|
||||||
|
var endLocation: LocationInput? = nil
|
||||||
|
|
||||||
// MARK: - Planning State
|
// MARK: - Planning State
|
||||||
|
|
||||||
var isPlanning: Bool = false
|
var isPlanning: Bool = false
|
||||||
@@ -65,26 +81,65 @@ final class TripWizardViewModel {
|
|||||||
planningMode != nil
|
planningMode != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Mode-specific step visibility
|
||||||
|
var showDatesStep: Bool {
|
||||||
|
planningMode == .dateRange || planningMode == .followTeam || planningMode == .locations
|
||||||
|
}
|
||||||
|
|
||||||
|
var showSportsStep: Bool {
|
||||||
|
planningMode == .dateRange || planningMode == .locations
|
||||||
|
}
|
||||||
|
|
||||||
|
var showRegionsStep: Bool {
|
||||||
|
planningMode == .dateRange
|
||||||
|
}
|
||||||
|
|
||||||
|
var showGamePickerStep: Bool {
|
||||||
|
planningMode == .gameFirst
|
||||||
|
}
|
||||||
|
|
||||||
|
var showTeamPickerStep: Bool {
|
||||||
|
planningMode == .followTeam
|
||||||
|
}
|
||||||
|
|
||||||
|
var showLocationsStep: Bool {
|
||||||
|
planningMode == .locations
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Validation
|
// MARK: - Validation
|
||||||
|
|
||||||
/// All required fields must be set before planning
|
/// All required fields must be set before planning
|
||||||
var canPlanTrip: Bool {
|
var canPlanTrip: Bool {
|
||||||
planningMode != nil &&
|
guard let mode = planningMode else { return false }
|
||||||
hasSetDates &&
|
|
||||||
!selectedSports.isEmpty &&
|
// Common requirements for all modes
|
||||||
!selectedRegions.isEmpty &&
|
guard hasSetRoutePreference && hasSetRepeatCities else { return false }
|
||||||
hasSetRoutePreference &&
|
|
||||||
hasSetRepeatCities
|
switch mode {
|
||||||
|
case .dateRange:
|
||||||
|
return hasSetDates && !selectedSports.isEmpty && !selectedRegions.isEmpty
|
||||||
|
case .gameFirst:
|
||||||
|
return !selectedGameIds.isEmpty
|
||||||
|
case .locations:
|
||||||
|
return startLocation != nil && endLocation != nil && hasSetDates && !selectedSports.isEmpty
|
||||||
|
case .followTeam:
|
||||||
|
return selectedTeamId != nil && hasSetDates
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Field validation for the review step - shows which fields are missing
|
/// Field validation for the review step - shows which fields are missing
|
||||||
var fieldValidation: FieldValidation {
|
var fieldValidation: FieldValidation {
|
||||||
FieldValidation(
|
FieldValidation(
|
||||||
|
planningMode: planningMode,
|
||||||
sports: selectedSports.isEmpty ? .missing : .valid,
|
sports: selectedSports.isEmpty ? .missing : .valid,
|
||||||
dates: hasSetDates ? .valid : .missing,
|
dates: hasSetDates ? .valid : .missing,
|
||||||
regions: selectedRegions.isEmpty ? .missing : .valid,
|
regions: selectedRegions.isEmpty ? .missing : .valid,
|
||||||
routePreference: hasSetRoutePreference ? .valid : .missing,
|
routePreference: hasSetRoutePreference ? .valid : .missing,
|
||||||
repeatCities: hasSetRepeatCities ? .valid : .missing
|
repeatCities: hasSetRepeatCities ? .valid : .missing,
|
||||||
|
selectedGames: selectedGameIds.isEmpty ? .missing : .valid,
|
||||||
|
selectedTeam: selectedTeamId == nil ? .missing : .valid,
|
||||||
|
startLocation: startLocation == nil ? .missing : .valid,
|
||||||
|
endLocation: endLocation == nil ? .missing : .valid
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,12 +178,26 @@ final class TripWizardViewModel {
|
|||||||
// MARK: - Reset Logic
|
// MARK: - Reset Logic
|
||||||
|
|
||||||
private func resetAllSelections() {
|
private func resetAllSelections() {
|
||||||
|
// Common fields
|
||||||
selectedSports = []
|
selectedSports = []
|
||||||
hasSetDates = false
|
hasSetDates = false
|
||||||
selectedRegions = []
|
selectedRegions = []
|
||||||
hasSetRoutePreference = false
|
hasSetRoutePreference = false
|
||||||
hasSetRepeatCities = false
|
hasSetRepeatCities = false
|
||||||
mustStopLocations = []
|
mustStopLocations = []
|
||||||
|
|
||||||
|
// gameFirst mode fields
|
||||||
|
gamePickerSports = []
|
||||||
|
gamePickerTeamIds = []
|
||||||
|
selectedGameIds = []
|
||||||
|
|
||||||
|
// followTeam mode fields
|
||||||
|
selectedTeamId = nil
|
||||||
|
teamPickerSport = nil
|
||||||
|
|
||||||
|
// locations mode fields
|
||||||
|
startLocation = nil
|
||||||
|
endLocation = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,9 +209,70 @@ struct FieldValidation {
|
|||||||
case missing
|
case missing
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let planningMode: PlanningMode?
|
||||||
|
|
||||||
|
// Common fields
|
||||||
let sports: Status
|
let sports: Status
|
||||||
let dates: Status
|
let dates: Status
|
||||||
let regions: Status
|
let regions: Status
|
||||||
let routePreference: Status
|
let routePreference: Status
|
||||||
let repeatCities: Status
|
let repeatCities: Status
|
||||||
|
|
||||||
|
// Mode-specific fields
|
||||||
|
let selectedGames: Status
|
||||||
|
let selectedTeam: Status
|
||||||
|
let startLocation: Status
|
||||||
|
let endLocation: Status
|
||||||
|
|
||||||
|
/// Returns only the fields that are required for the current planning mode
|
||||||
|
var requiredFields: [(name: String, status: Status)] {
|
||||||
|
var fields: [(String, Status)] = []
|
||||||
|
|
||||||
|
guard let mode = planningMode else { return fields }
|
||||||
|
|
||||||
|
switch mode {
|
||||||
|
case .dateRange:
|
||||||
|
fields = [
|
||||||
|
("Dates", dates),
|
||||||
|
("Sports", sports),
|
||||||
|
("Regions", regions),
|
||||||
|
("Route Preference", routePreference),
|
||||||
|
("Repeat Cities", repeatCities)
|
||||||
|
]
|
||||||
|
case .gameFirst:
|
||||||
|
fields = [
|
||||||
|
("Games", selectedGames),
|
||||||
|
("Route Preference", routePreference),
|
||||||
|
("Repeat Cities", repeatCities)
|
||||||
|
]
|
||||||
|
case .locations:
|
||||||
|
fields = [
|
||||||
|
("Start Location", startLocation),
|
||||||
|
("End Location", endLocation),
|
||||||
|
("Dates", dates),
|
||||||
|
("Sports", sports),
|
||||||
|
("Route Preference", routePreference),
|
||||||
|
("Repeat Cities", repeatCities)
|
||||||
|
]
|
||||||
|
case .followTeam:
|
||||||
|
fields = [
|
||||||
|
("Team", selectedTeam),
|
||||||
|
("Dates", dates),
|
||||||
|
("Route Preference", routePreference),
|
||||||
|
("Repeat Cities", repeatCities)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
return fields
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns only the missing required fields for the current mode
|
||||||
|
var missingFields: [String] {
|
||||||
|
requiredFields.filter { $0.status == .missing }.map { $0.name }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether all required fields for the current mode are valid
|
||||||
|
var allRequiredFieldsValid: Bool {
|
||||||
|
requiredFields.allSatisfy { $0.status == .valid }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
524
SportsTime/Features/Trip/Views/Wizard/Steps/GamePickerStep.swift
Normal file
524
SportsTime/Features/Trip/Views/Wizard/Steps/GamePickerStep.swift
Normal file
@@ -0,0 +1,524 @@
|
|||||||
|
//
|
||||||
|
// GamePickerStep.swift
|
||||||
|
// SportsTime
|
||||||
|
//
|
||||||
|
// Game selection step for "By Games" planning mode.
|
||||||
|
// Uses sheet-based drill-down: Sports → Teams → Games.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct GamePickerStep: View {
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
|
||||||
|
@Binding var selectedSports: Set<Sport>
|
||||||
|
@Binding var selectedTeamIds: Set<String>
|
||||||
|
@Binding var selectedGameIds: Set<String>
|
||||||
|
|
||||||
|
@State private var showSportsPicker = false
|
||||||
|
@State private var showTeamsPicker = false
|
||||||
|
@State private var showGamesPicker = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: Theme.Spacing.md) {
|
||||||
|
StepHeader(
|
||||||
|
title: "Select games for your trip",
|
||||||
|
subtitle: "Pick sports, then teams, then games"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Step 1: Sports Selection
|
||||||
|
selectionRow(
|
||||||
|
icon: "sportscourt.fill",
|
||||||
|
label: "Sports",
|
||||||
|
value: selectedSports.isEmpty ? nil : selectedSports.map(\.rawValue).sorted().joined(separator: ", "),
|
||||||
|
placeholder: "Select sports",
|
||||||
|
onTap: { showSportsPicker = true },
|
||||||
|
onClear: {
|
||||||
|
selectedSports = []
|
||||||
|
selectedTeamIds = []
|
||||||
|
selectedGameIds = []
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Step 2: Teams Selection (enabled after sports)
|
||||||
|
selectionRow(
|
||||||
|
icon: "person.2.fill",
|
||||||
|
label: "Teams",
|
||||||
|
value: selectedTeamIds.isEmpty ? nil : "\(selectedTeamIds.count) team\(selectedTeamIds.count == 1 ? "" : "s")",
|
||||||
|
placeholder: "Select teams",
|
||||||
|
isEnabled: !selectedSports.isEmpty,
|
||||||
|
onTap: { showTeamsPicker = true },
|
||||||
|
onClear: {
|
||||||
|
selectedTeamIds = []
|
||||||
|
selectedGameIds = []
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Step 3: Games Selection (enabled after teams)
|
||||||
|
selectionRow(
|
||||||
|
icon: "ticket.fill",
|
||||||
|
label: "Games",
|
||||||
|
value: selectedGameIds.isEmpty ? nil : "\(selectedGameIds.count) game\(selectedGameIds.count == 1 ? "" : "s")",
|
||||||
|
placeholder: "Select games",
|
||||||
|
isEnabled: !selectedTeamIds.isEmpty,
|
||||||
|
onTap: { showGamesPicker = true },
|
||||||
|
onClear: { selectedGameIds = [] }
|
||||||
|
)
|
||||||
|
|
||||||
|
// Selected Games Summary
|
||||||
|
if !selectedGameIds.isEmpty {
|
||||||
|
selectedGamesSummary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.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)
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showSportsPicker) {
|
||||||
|
SportsPickerSheet(selectedSports: $selectedSports) {
|
||||||
|
// Clear downstream when sports change
|
||||||
|
selectedTeamIds = []
|
||||||
|
selectedGameIds = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showTeamsPicker) {
|
||||||
|
TeamsPickerSheet(
|
||||||
|
selectedSports: selectedSports,
|
||||||
|
selectedTeamIds: $selectedTeamIds
|
||||||
|
) {
|
||||||
|
// Clear games when teams change
|
||||||
|
selectedGameIds = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showGamesPicker) {
|
||||||
|
GamesPickerSheet(
|
||||||
|
selectedTeamIds: selectedTeamIds,
|
||||||
|
selectedGameIds: $selectedGameIds
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Selection Row
|
||||||
|
|
||||||
|
private func selectionRow(
|
||||||
|
icon: String,
|
||||||
|
label: String,
|
||||||
|
value: String?,
|
||||||
|
placeholder: String,
|
||||||
|
isEnabled: Bool = true,
|
||||||
|
onTap: @escaping () -> Void,
|
||||||
|
onClear: @escaping () -> Void
|
||||||
|
) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
|
||||||
|
Text(label)
|
||||||
|
.font(.caption)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||||
|
|
||||||
|
Button {
|
||||||
|
if isEnabled { onTap() }
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.foregroundStyle(isEnabled ? Theme.warmOrange : Theme.textMuted(colorScheme))
|
||||||
|
|
||||||
|
if let value = value {
|
||||||
|
Text(value)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button(action: onClear) {
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text(placeholder)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(Theme.Spacing.md)
|
||||||
|
.background(Theme.cardBackgroundElevated(colorScheme))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
|
||||||
|
.stroke(value != nil ? Theme.warmOrange : Theme.textMuted(colorScheme).opacity(0.3), lineWidth: value != nil ? 2 : 1)
|
||||||
|
)
|
||||||
|
.opacity(isEnabled ? 1 : 0.5)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(!isEnabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Selected Games Summary
|
||||||
|
|
||||||
|
@State private var summaryGames: [RichGame] = []
|
||||||
|
|
||||||
|
private var selectedGamesSummary: some View {
|
||||||
|
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.foregroundStyle(Theme.warmOrange)
|
||||||
|
Text("\(selectedGameIds.count) game\(selectedGameIds.count == 1 ? "" : "s") selected")
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||||
|
}
|
||||||
|
|
||||||
|
ForEach(summaryGames.filter { selectedGameIds.contains($0.id) }) { game in
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(game.matchupDescription)
|
||||||
|
.font(.caption)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||||
|
Text("\(game.stadium.city) • \(game.game.dateTime, style: .date)")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Button {
|
||||||
|
selectedGameIds.remove(game.id)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(Theme.Spacing.sm)
|
||||||
|
.background(Theme.cardBackgroundElevated(colorScheme))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.small))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(Theme.Spacing.sm)
|
||||||
|
.background(Theme.warmOrange.opacity(0.1))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
|
||||||
|
.task(id: selectedGameIds) {
|
||||||
|
await loadSummaryGames()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadSummaryGames() async {
|
||||||
|
var games: [RichGame] = []
|
||||||
|
for teamId in selectedTeamIds {
|
||||||
|
if let teamGames = try? await AppDataProvider.shared.gamesForTeam(teamId: teamId) {
|
||||||
|
games.append(contentsOf: teamGames)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await MainActor.run {
|
||||||
|
summaryGames = Array(Set(games))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sports Picker Sheet
|
||||||
|
|
||||||
|
private struct SportsPickerSheet: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
|
||||||
|
@Binding var selectedSports: Set<Sport>
|
||||||
|
let onChanged: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
List {
|
||||||
|
ForEach(Sport.supported, id: \.self) { sport in
|
||||||
|
Button {
|
||||||
|
if selectedSports.contains(sport) {
|
||||||
|
selectedSports.remove(sport)
|
||||||
|
} else {
|
||||||
|
selectedSports.insert(sport)
|
||||||
|
}
|
||||||
|
onChanged()
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: Theme.Spacing.sm) {
|
||||||
|
Image(systemName: sport.iconName)
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(sport.themeColor)
|
||||||
|
.frame(width: 32)
|
||||||
|
|
||||||
|
Text(sport.rawValue)
|
||||||
|
.font(.body)
|
||||||
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if selectedSports.contains(sport) {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.foregroundStyle(Theme.warmOrange)
|
||||||
|
} else {
|
||||||
|
Image(systemName: "circle")
|
||||||
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, Theme.Spacing.xs)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
.navigationTitle("Select Sports")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Cancel") { dismiss() }
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Done") { dismiss() }
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.presentationDetents([.medium])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Teams Picker Sheet
|
||||||
|
|
||||||
|
private struct TeamsPickerSheet: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
|
||||||
|
let selectedSports: Set<Sport>
|
||||||
|
@Binding var selectedTeamIds: Set<String>
|
||||||
|
let onChanged: () -> Void
|
||||||
|
|
||||||
|
@State private var searchText = ""
|
||||||
|
|
||||||
|
private var teams: [Team] {
|
||||||
|
let allTeams = AppDataProvider.shared.teams
|
||||||
|
.filter { selectedSports.contains($0.sport) }
|
||||||
|
.sorted { $0.fullName < $1.fullName }
|
||||||
|
|
||||||
|
if searchText.isEmpty {
|
||||||
|
return allTeams
|
||||||
|
}
|
||||||
|
|
||||||
|
return allTeams.filter {
|
||||||
|
$0.fullName.localizedCaseInsensitiveContains(searchText) ||
|
||||||
|
$0.city.localizedCaseInsensitiveContains(searchText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var groupedTeams: [(Sport, [Team])] {
|
||||||
|
let grouped = Dictionary(grouping: teams) { $0.sport }
|
||||||
|
return selectedSports.sorted { $0.rawValue < $1.rawValue }
|
||||||
|
.compactMap { sport in
|
||||||
|
guard let sportTeams = grouped[sport], !sportTeams.isEmpty else { return nil }
|
||||||
|
return (sport, sportTeams)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
List {
|
||||||
|
ForEach(groupedTeams, id: \.0) { sport, sportTeams in
|
||||||
|
Section {
|
||||||
|
ForEach(sportTeams) { team in
|
||||||
|
Button {
|
||||||
|
if selectedTeamIds.contains(team.id) {
|
||||||
|
selectedTeamIds.remove(team.id)
|
||||||
|
} else {
|
||||||
|
selectedTeamIds.insert(team.id)
|
||||||
|
}
|
||||||
|
onChanged()
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: Theme.Spacing.sm) {
|
||||||
|
Circle()
|
||||||
|
.fill(team.primaryColor.map { Color(hex: $0) } ?? sport.themeColor)
|
||||||
|
.frame(width: 28, height: 28)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(team.fullName)
|
||||||
|
.font(.body)
|
||||||
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||||
|
|
||||||
|
Text(team.city)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if selectedTeamIds.contains(team.id) {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.foregroundStyle(Theme.warmOrange)
|
||||||
|
} else {
|
||||||
|
Image(systemName: "circle")
|
||||||
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, Theme.Spacing.xs)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
HStack(spacing: Theme.Spacing.xs) {
|
||||||
|
Image(systemName: sport.iconName)
|
||||||
|
.foregroundStyle(sport.themeColor)
|
||||||
|
Text(sport.rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
.searchable(text: $searchText, prompt: "Search teams")
|
||||||
|
.navigationTitle("Select Teams")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Cancel") { dismiss() }
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Done") { dismiss() }
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.presentationDetents([.large])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Games Picker Sheet
|
||||||
|
|
||||||
|
private struct GamesPickerSheet: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
|
||||||
|
let selectedTeamIds: Set<String>
|
||||||
|
@Binding var selectedGameIds: Set<String>
|
||||||
|
|
||||||
|
@State private var games: [RichGame] = []
|
||||||
|
@State private var isLoading = true
|
||||||
|
|
||||||
|
private var groupedGames: [(Date, [RichGame])] {
|
||||||
|
let grouped = Dictionary(grouping: games) { game in
|
||||||
|
Calendar.current.startOfDay(for: game.game.dateTime)
|
||||||
|
}
|
||||||
|
return grouped.keys.sorted().map { date in
|
||||||
|
(date, grouped[date] ?? [])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Group {
|
||||||
|
if isLoading {
|
||||||
|
ProgressView("Loading games...")
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
} else if games.isEmpty {
|
||||||
|
ContentUnavailableView(
|
||||||
|
"No Games Found",
|
||||||
|
systemImage: "ticket",
|
||||||
|
description: Text("No upcoming games found for the selected teams")
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
List {
|
||||||
|
ForEach(groupedGames, id: \.0) { date, dateGames in
|
||||||
|
Section {
|
||||||
|
ForEach(dateGames) { game in
|
||||||
|
Button {
|
||||||
|
if selectedGameIds.contains(game.id) {
|
||||||
|
selectedGameIds.remove(game.id)
|
||||||
|
} else {
|
||||||
|
selectedGameIds.insert(game.id)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
HStack(spacing: Theme.Spacing.xs) {
|
||||||
|
Image(systemName: game.game.sport.iconName)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(game.game.sport.themeColor)
|
||||||
|
|
||||||
|
Text(game.matchupDescription)
|
||||||
|
.font(.body)
|
||||||
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("\(game.stadium.name) • \(game.localGameTimeShort)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if selectedGameIds.contains(game.id) {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.foregroundStyle(Theme.warmOrange)
|
||||||
|
} else {
|
||||||
|
Image(systemName: "circle")
|
||||||
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, Theme.Spacing.xs)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text(date, style: .date)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Select Games")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Cancel") { dismiss() }
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Done") { dismiss() }
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.presentationDetents([.large])
|
||||||
|
.task {
|
||||||
|
await loadGames()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadGames() async {
|
||||||
|
var allGames: [RichGame] = []
|
||||||
|
for teamId in selectedTeamIds {
|
||||||
|
if let teamGames = try? await AppDataProvider.shared.gamesForTeam(teamId: teamId) {
|
||||||
|
let futureGames = teamGames.filter { $0.game.dateTime > Date() }
|
||||||
|
allGames.append(contentsOf: futureGames)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let uniqueGames = Array(Set(allGames)).sorted { $0.game.dateTime < $1.game.dateTime }
|
||||||
|
await MainActor.run {
|
||||||
|
games = uniqueGames
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Preview
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
GamePickerStep(
|
||||||
|
selectedSports: .constant([.mlb]),
|
||||||
|
selectedTeamIds: .constant([]),
|
||||||
|
selectedGameIds: .constant([])
|
||||||
|
)
|
||||||
|
.padding()
|
||||||
|
.themedBackground()
|
||||||
|
}
|
||||||
@@ -31,6 +31,16 @@ struct LocationSearchSheet: View {
|
|||||||
|
|
||||||
private let locationService = LocationService.shared
|
private let locationService = LocationService.shared
|
||||||
|
|
||||||
|
private var navigationTitle: String {
|
||||||
|
switch inputType {
|
||||||
|
case .mustStop: return "Add Must-Stop"
|
||||||
|
case .preferred: return "Add Preferred Location"
|
||||||
|
case .homeLocation: return "Set Home Location"
|
||||||
|
case .startLocation: return "Set Start Location"
|
||||||
|
case .endLocation: return "Set End Location"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
@@ -96,7 +106,7 @@ struct LocationSearchSheet: View {
|
|||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.navigationTitle(inputType == .mustStop ? "Add Must-Stop" : "Add Location")
|
.navigationTitle(navigationTitle)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .cancellationAction) {
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
|||||||
175
SportsTime/Features/Trip/Views/Wizard/Steps/LocationsStep.swift
Normal file
175
SportsTime/Features/Trip/Views/Wizard/Steps/LocationsStep.swift
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
//
|
||||||
|
// LocationsStep.swift
|
||||||
|
// SportsTime
|
||||||
|
//
|
||||||
|
// Start and end location selection for "By Route" planning mode.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct LocationsStep: View {
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
|
||||||
|
@Binding var startLocation: LocationInput?
|
||||||
|
@Binding var endLocation: LocationInput?
|
||||||
|
|
||||||
|
@State private var showStartLocationSearch = false
|
||||||
|
@State private var showEndLocationSearch = false
|
||||||
|
@State private var isRoundTrip = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: Theme.Spacing.md) {
|
||||||
|
StepHeader(
|
||||||
|
title: "Where are you traveling?",
|
||||||
|
subtitle: "Set your start and end points"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Start Location
|
||||||
|
locationRow(
|
||||||
|
label: "Starting from",
|
||||||
|
location: startLocation,
|
||||||
|
placeholder: "Select start city",
|
||||||
|
onTap: { showStartLocationSearch = true },
|
||||||
|
onClear: { startLocation = nil }
|
||||||
|
)
|
||||||
|
|
||||||
|
// End Location
|
||||||
|
if !isRoundTrip {
|
||||||
|
locationRow(
|
||||||
|
label: "Ending at",
|
||||||
|
location: endLocation,
|
||||||
|
placeholder: "Select end city",
|
||||||
|
onTap: { showEndLocationSearch = true },
|
||||||
|
onClear: { endLocation = nil }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Round Trip Toggle
|
||||||
|
Toggle(isOn: $isRoundTrip) {
|
||||||
|
HStack(spacing: Theme.Spacing.sm) {
|
||||||
|
Image(systemName: "arrow.triangle.2.circlepath")
|
||||||
|
.foregroundStyle(Theme.warmOrange)
|
||||||
|
Text("Round trip (return to start)")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.toggleStyle(SwitchToggleStyle(tint: Theme.warmOrange))
|
||||||
|
.onChange(of: isRoundTrip) { _, newValue in
|
||||||
|
if newValue {
|
||||||
|
endLocation = startLocation
|
||||||
|
} else {
|
||||||
|
endLocation = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: startLocation) { _, newValue in
|
||||||
|
if isRoundTrip {
|
||||||
|
endLocation = newValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.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)
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showStartLocationSearch) {
|
||||||
|
LocationSearchSheet(inputType: .startLocation) { location in
|
||||||
|
startLocation = location
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showEndLocationSearch) {
|
||||||
|
LocationSearchSheet(inputType: .endLocation) { location in
|
||||||
|
endLocation = location
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Location Row
|
||||||
|
|
||||||
|
private func locationRow(
|
||||||
|
label: String,
|
||||||
|
location: LocationInput?,
|
||||||
|
placeholder: String,
|
||||||
|
onTap: @escaping () -> Void,
|
||||||
|
onClear: @escaping () -> Void
|
||||||
|
) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
|
||||||
|
Text(label)
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||||
|
|
||||||
|
if let location = location {
|
||||||
|
// Selected location
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "mappin.circle.fill")
|
||||||
|
.foregroundStyle(Theme.warmOrange)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(location.name)
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||||
|
|
||||||
|
if let address = location.address, !address.isEmpty {
|
||||||
|
Text(address)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button(action: onClear) {
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(Theme.Spacing.sm)
|
||||||
|
.background(Theme.cardBackgroundElevated(colorScheme))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
|
||||||
|
} else {
|
||||||
|
// Empty state - tap to add
|
||||||
|
Button(action: onTap) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "plus.circle")
|
||||||
|
.foregroundStyle(Theme.warmOrange)
|
||||||
|
|
||||||
|
Text(placeholder)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||||
|
}
|
||||||
|
.padding(Theme.Spacing.sm)
|
||||||
|
.background(Theme.cardBackgroundElevated(colorScheme))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
|
||||||
|
.stroke(Theme.textMuted(colorScheme).opacity(0.3), lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Preview
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
LocationsStep(
|
||||||
|
startLocation: .constant(nil),
|
||||||
|
endLocation: .constant(nil)
|
||||||
|
)
|
||||||
|
.padding()
|
||||||
|
.themedBackground()
|
||||||
|
}
|
||||||
@@ -22,6 +22,12 @@ struct ReviewStep: View {
|
|||||||
let fieldValidation: FieldValidation
|
let fieldValidation: FieldValidation
|
||||||
let onPlan: () -> Void
|
let onPlan: () -> Void
|
||||||
|
|
||||||
|
// Mode-specific display values (passed from parent)
|
||||||
|
var selectedGameCount: Int = 0
|
||||||
|
var selectedTeamName: String? = nil
|
||||||
|
var startLocationName: String? = nil
|
||||||
|
var endLocationName: String? = nil
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: Theme.Spacing.md) {
|
VStack(alignment: .leading, spacing: Theme.Spacing.md) {
|
||||||
StepHeader(
|
StepHeader(
|
||||||
@@ -30,33 +36,19 @@ struct ReviewStep: View {
|
|||||||
)
|
)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
||||||
|
// Mode (always shown)
|
||||||
ReviewRow(label: "Mode", value: planningMode.displayName)
|
ReviewRow(label: "Mode", value: planningMode.displayName)
|
||||||
ReviewRow(
|
|
||||||
label: "Sports",
|
|
||||||
value: selectedSports.isEmpty ? "Not selected" : selectedSports.map(\.rawValue).sorted().joined(separator: ", "),
|
|
||||||
isMissing: fieldValidation.sports == .missing
|
|
||||||
)
|
|
||||||
ReviewRow(
|
|
||||||
label: "Dates",
|
|
||||||
value: dateRangeText,
|
|
||||||
isMissing: fieldValidation.dates == .missing
|
|
||||||
)
|
|
||||||
ReviewRow(
|
|
||||||
label: "Regions",
|
|
||||||
value: selectedRegions.isEmpty ? "Not selected" : selectedRegions.map(\.shortName).sorted().joined(separator: ", "),
|
|
||||||
isMissing: fieldValidation.regions == .missing
|
|
||||||
)
|
|
||||||
ReviewRow(
|
|
||||||
label: "Route",
|
|
||||||
value: routePreference.displayName,
|
|
||||||
isMissing: fieldValidation.routePreference == .missing
|
|
||||||
)
|
|
||||||
ReviewRow(
|
|
||||||
label: "Repeat cities",
|
|
||||||
value: allowRepeatCities ? "Yes" : "No",
|
|
||||||
isMissing: fieldValidation.repeatCities == .missing
|
|
||||||
)
|
|
||||||
|
|
||||||
|
// Mode-specific required fields
|
||||||
|
ForEach(fieldValidation.requiredFields, id: \.name) { field in
|
||||||
|
ReviewRow(
|
||||||
|
label: field.name,
|
||||||
|
value: displayValue(for: field.name),
|
||||||
|
isMissing: field.status == .missing
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional: Must-stops (shown if any selected)
|
||||||
if !mustStopLocations.isEmpty {
|
if !mustStopLocations.isEmpty {
|
||||||
ReviewRow(label: "Must-stops", value: mustStopLocations.map(\.name).joined(separator: ", "))
|
ReviewRow(label: "Must-stops", value: mustStopLocations.map(\.name).joined(separator: ", "))
|
||||||
}
|
}
|
||||||
@@ -65,6 +57,17 @@ struct ReviewStep: View {
|
|||||||
.background(Theme.cardBackgroundElevated(colorScheme))
|
.background(Theme.cardBackgroundElevated(colorScheme))
|
||||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
|
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
|
||||||
|
|
||||||
|
// Missing fields warning
|
||||||
|
if !fieldValidation.missingFields.isEmpty {
|
||||||
|
HStack(spacing: Theme.Spacing.xs) {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
Text("Complete all required fields to continue")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Button(action: onPlan) {
|
Button(action: onPlan) {
|
||||||
HStack(spacing: Theme.Spacing.sm) {
|
HStack(spacing: Theme.Spacing.sm) {
|
||||||
if isPlanning {
|
if isPlanning {
|
||||||
@@ -91,6 +94,31 @@ struct ReviewStep: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func displayValue(for fieldName: String) -> String {
|
||||||
|
switch fieldName {
|
||||||
|
case "Sports":
|
||||||
|
return selectedSports.isEmpty ? "Not selected" : selectedSports.map(\.rawValue).sorted().joined(separator: ", ")
|
||||||
|
case "Dates":
|
||||||
|
return dateRangeText
|
||||||
|
case "Regions":
|
||||||
|
return selectedRegions.isEmpty ? "Not selected" : selectedRegions.map(\.shortName).sorted().joined(separator: ", ")
|
||||||
|
case "Route Preference":
|
||||||
|
return routePreference.displayName
|
||||||
|
case "Repeat Cities":
|
||||||
|
return allowRepeatCities ? "Yes" : "No"
|
||||||
|
case "Games":
|
||||||
|
return selectedGameCount > 0 ? "\(selectedGameCount) game\(selectedGameCount == 1 ? "" : "s") selected" : "Not selected"
|
||||||
|
case "Team":
|
||||||
|
return selectedTeamName ?? "Not selected"
|
||||||
|
case "Start Location":
|
||||||
|
return startLocationName ?? "Not selected"
|
||||||
|
case "End Location":
|
||||||
|
return endLocationName ?? "Not selected"
|
||||||
|
default:
|
||||||
|
return "—"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var dateRangeText: String {
|
private var dateRangeText: String {
|
||||||
let formatter = DateFormatter()
|
let formatter = DateFormatter()
|
||||||
formatter.dateStyle = .medium
|
formatter.dateStyle = .medium
|
||||||
@@ -143,11 +171,16 @@ private struct ReviewRow: View {
|
|||||||
isPlanning: false,
|
isPlanning: false,
|
||||||
canPlanTrip: true,
|
canPlanTrip: true,
|
||||||
fieldValidation: FieldValidation(
|
fieldValidation: FieldValidation(
|
||||||
|
planningMode: .dateRange,
|
||||||
sports: .valid,
|
sports: .valid,
|
||||||
dates: .valid,
|
dates: .valid,
|
||||||
regions: .valid,
|
regions: .valid,
|
||||||
routePreference: .valid,
|
routePreference: .valid,
|
||||||
repeatCities: .valid
|
repeatCities: .valid,
|
||||||
|
selectedGames: .valid,
|
||||||
|
selectedTeam: .valid,
|
||||||
|
startLocation: .valid,
|
||||||
|
endLocation: .valid
|
||||||
),
|
),
|
||||||
onPlan: {}
|
onPlan: {}
|
||||||
)
|
)
|
||||||
@@ -168,11 +201,16 @@ private struct ReviewRow: View {
|
|||||||
isPlanning: false,
|
isPlanning: false,
|
||||||
canPlanTrip: false,
|
canPlanTrip: false,
|
||||||
fieldValidation: FieldValidation(
|
fieldValidation: FieldValidation(
|
||||||
|
planningMode: .dateRange,
|
||||||
sports: .missing,
|
sports: .missing,
|
||||||
dates: .valid,
|
dates: .valid,
|
||||||
regions: .missing,
|
regions: .missing,
|
||||||
routePreference: .valid,
|
routePreference: .valid,
|
||||||
repeatCities: .missing
|
repeatCities: .missing,
|
||||||
|
selectedGames: .valid,
|
||||||
|
selectedTeam: .valid,
|
||||||
|
startLocation: .valid,
|
||||||
|
endLocation: .valid
|
||||||
),
|
),
|
||||||
onPlan: {}
|
onPlan: {}
|
||||||
)
|
)
|
||||||
|
|||||||
240
SportsTime/Features/Trip/Views/Wizard/Steps/TeamPickerStep.swift
Normal file
240
SportsTime/Features/Trip/Views/Wizard/Steps/TeamPickerStep.swift
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
//
|
||||||
|
// TeamPickerStep.swift
|
||||||
|
// SportsTime
|
||||||
|
//
|
||||||
|
// Team selection step for "Follow Team" planning mode.
|
||||||
|
// Uses sheet-based drill-down: Sport → Team.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct TeamPickerStep: View {
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
|
||||||
|
@Binding var selectedSport: Sport?
|
||||||
|
@Binding var selectedTeamId: String?
|
||||||
|
|
||||||
|
@State private var showTeamPicker = false
|
||||||
|
|
||||||
|
private var selectedTeam: Team? {
|
||||||
|
guard let teamId = selectedTeamId else { return nil }
|
||||||
|
return AppDataProvider.shared.teams.first { $0.id == teamId }
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: Theme.Spacing.md) {
|
||||||
|
StepHeader(
|
||||||
|
title: "Which team do you want to follow?",
|
||||||
|
subtitle: "See their home and away games"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Selection button
|
||||||
|
Button {
|
||||||
|
showTeamPicker = true
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
if let team = selectedTeam {
|
||||||
|
// Show selected team
|
||||||
|
Circle()
|
||||||
|
.fill(team.primaryColor.map { Color(hex: $0) } ?? team.sport.themeColor)
|
||||||
|
.frame(width: 24, height: 24)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(team.fullName)
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||||
|
|
||||||
|
Text(team.sport.rawValue)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button {
|
||||||
|
selectedTeamId = nil
|
||||||
|
selectedSport = nil
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Empty state
|
||||||
|
Image(systemName: "person.2.fill")
|
||||||
|
.foregroundStyle(Theme.warmOrange)
|
||||||
|
|
||||||
|
Text("Select a team")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(Theme.Spacing.md)
|
||||||
|
.background(Theme.cardBackgroundElevated(colorScheme))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
|
||||||
|
.stroke(selectedTeam != nil ? Theme.warmOrange : Theme.textMuted(colorScheme).opacity(0.3), lineWidth: selectedTeam != nil ? 2 : 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
.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)
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showTeamPicker) {
|
||||||
|
TeamPickerSheet(
|
||||||
|
selectedSport: $selectedSport,
|
||||||
|
selectedTeamId: $selectedTeamId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Team Picker Sheet
|
||||||
|
|
||||||
|
private struct TeamPickerSheet: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
|
||||||
|
@Binding var selectedSport: Sport?
|
||||||
|
@Binding var selectedTeamId: String?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
List {
|
||||||
|
ForEach(Sport.supported, id: \.self) { sport in
|
||||||
|
NavigationLink {
|
||||||
|
TeamListView(
|
||||||
|
sport: sport,
|
||||||
|
selectedTeamId: $selectedTeamId,
|
||||||
|
onSelect: { teamId in
|
||||||
|
selectedSport = sport
|
||||||
|
selectedTeamId = teamId
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: Theme.Spacing.sm) {
|
||||||
|
Image(systemName: sport.iconName)
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(sport.themeColor)
|
||||||
|
.frame(width: 32)
|
||||||
|
|
||||||
|
Text(sport.rawValue)
|
||||||
|
.font(.body)
|
||||||
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Text("\(teamsCount(for: sport)) teams")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||||
|
}
|
||||||
|
.padding(.vertical, Theme.Spacing.xs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
.navigationTitle("Select Sport")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Cancel") { dismiss() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.presentationDetents([.large])
|
||||||
|
}
|
||||||
|
|
||||||
|
private func teamsCount(for sport: Sport) -> Int {
|
||||||
|
AppDataProvider.shared.teams.filter { $0.sport == sport }.count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Team List View
|
||||||
|
|
||||||
|
private struct TeamListView: View {
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
|
||||||
|
let sport: Sport
|
||||||
|
@Binding var selectedTeamId: String?
|
||||||
|
let onSelect: (String) -> Void
|
||||||
|
|
||||||
|
@State private var searchText = ""
|
||||||
|
|
||||||
|
private var teams: [Team] {
|
||||||
|
let allTeams = AppDataProvider.shared.teams
|
||||||
|
.filter { $0.sport == sport }
|
||||||
|
.sorted { $0.fullName < $1.fullName }
|
||||||
|
|
||||||
|
if searchText.isEmpty {
|
||||||
|
return allTeams
|
||||||
|
}
|
||||||
|
|
||||||
|
return allTeams.filter {
|
||||||
|
$0.fullName.localizedCaseInsensitiveContains(searchText) ||
|
||||||
|
$0.city.localizedCaseInsensitiveContains(searchText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
ForEach(teams) { team in
|
||||||
|
Button {
|
||||||
|
onSelect(team.id)
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: Theme.Spacing.sm) {
|
||||||
|
Circle()
|
||||||
|
.fill(team.primaryColor.map { Color(hex: $0) } ?? sport.themeColor)
|
||||||
|
.frame(width: 28, height: 28)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(team.fullName)
|
||||||
|
.font(.body)
|
||||||
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||||
|
|
||||||
|
Text(team.city)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if selectedTeamId == team.id {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.foregroundStyle(Theme.warmOrange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, Theme.Spacing.xs)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
.searchable(text: $searchText, prompt: "Search teams")
|
||||||
|
.navigationTitle(sport.rawValue)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Preview
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
TeamPickerStep(
|
||||||
|
selectedSport: .constant(.mlb),
|
||||||
|
selectedTeamId: .constant(nil)
|
||||||
|
)
|
||||||
|
.padding()
|
||||||
|
.themedBackground()
|
||||||
|
}
|
||||||
@@ -19,6 +19,12 @@ struct TripWizardView: View {
|
|||||||
|
|
||||||
private let planningEngine = TripPlanningEngine()
|
private let planningEngine = TripPlanningEngine()
|
||||||
|
|
||||||
|
/// Selected team name for display in ReviewStep
|
||||||
|
private var selectedTeamName: String? {
|
||||||
|
guard let teamId = viewModel.selectedTeamId else { return nil }
|
||||||
|
return AppDataProvider.shared.teams.first { $0.id == teamId }?.fullName
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
@@ -29,26 +35,57 @@ struct TripWizardView: View {
|
|||||||
// All other steps appear together after planning mode selected
|
// All other steps appear together after planning mode selected
|
||||||
if viewModel.areStepsVisible {
|
if viewModel.areStepsVisible {
|
||||||
Group {
|
Group {
|
||||||
DatesStep(
|
// Mode-specific steps
|
||||||
startDate: $viewModel.startDate,
|
if viewModel.showGamePickerStep {
|
||||||
endDate: $viewModel.endDate,
|
GamePickerStep(
|
||||||
hasSetDates: $viewModel.hasSetDates,
|
selectedSports: $viewModel.gamePickerSports,
|
||||||
onDatesChanged: {
|
selectedTeamIds: $viewModel.gamePickerTeamIds,
|
||||||
Task {
|
selectedGameIds: $viewModel.selectedGameIds
|
||||||
await viewModel.fetchSportAvailability()
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if viewModel.showTeamPickerStep {
|
||||||
|
TeamPickerStep(
|
||||||
|
selectedSport: $viewModel.teamPickerSport,
|
||||||
|
selectedTeamId: $viewModel.selectedTeamId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if viewModel.showLocationsStep {
|
||||||
|
LocationsStep(
|
||||||
|
startLocation: $viewModel.startLocation,
|
||||||
|
endLocation: $viewModel.endLocation
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common steps (conditionally shown)
|
||||||
|
if viewModel.showDatesStep {
|
||||||
|
DatesStep(
|
||||||
|
startDate: $viewModel.startDate,
|
||||||
|
endDate: $viewModel.endDate,
|
||||||
|
hasSetDates: $viewModel.hasSetDates,
|
||||||
|
onDatesChanged: {
|
||||||
|
Task {
|
||||||
|
await viewModel.fetchSportAvailability()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
)
|
}
|
||||||
|
|
||||||
SportsStep(
|
if viewModel.showSportsStep {
|
||||||
selectedSports: $viewModel.selectedSports,
|
SportsStep(
|
||||||
sportAvailability: viewModel.sportAvailability,
|
selectedSports: $viewModel.selectedSports,
|
||||||
isLoading: viewModel.isLoadingSportAvailability,
|
sportAvailability: viewModel.sportAvailability,
|
||||||
canSelectSport: viewModel.canSelectSport
|
isLoading: viewModel.isLoadingSportAvailability,
|
||||||
)
|
canSelectSport: viewModel.canSelectSport
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
RegionsStep(selectedRegions: $viewModel.selectedRegions)
|
if viewModel.showRegionsStep {
|
||||||
|
RegionsStep(selectedRegions: $viewModel.selectedRegions)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always shown steps
|
||||||
RoutePreferenceStep(
|
RoutePreferenceStep(
|
||||||
routePreference: $viewModel.routePreference,
|
routePreference: $viewModel.routePreference,
|
||||||
hasSetRoutePreference: $viewModel.hasSetRoutePreference
|
hasSetRoutePreference: $viewModel.hasSetRoutePreference
|
||||||
@@ -73,7 +110,11 @@ struct TripWizardView: View {
|
|||||||
isPlanning: viewModel.isPlanning,
|
isPlanning: viewModel.isPlanning,
|
||||||
canPlanTrip: viewModel.canPlanTrip,
|
canPlanTrip: viewModel.canPlanTrip,
|
||||||
fieldValidation: viewModel.fieldValidation,
|
fieldValidation: viewModel.fieldValidation,
|
||||||
onPlan: { Task { await planTrip() } }
|
onPlan: { Task { await planTrip() } },
|
||||||
|
selectedGameCount: viewModel.selectedGameIds.count,
|
||||||
|
selectedTeamName: selectedTeamName,
|
||||||
|
startLocationName: viewModel.startLocation?.name,
|
||||||
|
endLocationName: viewModel.endLocation?.name
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.transition(.opacity)
|
.transition(.opacity)
|
||||||
@@ -115,19 +156,61 @@ struct TripWizardView: View {
|
|||||||
defer { viewModel.isPlanning = false }
|
defer { viewModel.isPlanning = false }
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let preferences = buildPreferences()
|
var preferences = buildPreferences()
|
||||||
|
|
||||||
// Fetch games for selected sports and date range
|
|
||||||
let games = try await AppDataProvider.shared.filterGames(
|
|
||||||
sports: preferences.sports,
|
|
||||||
startDate: preferences.startDate,
|
|
||||||
endDate: preferences.endDate
|
|
||||||
)
|
|
||||||
|
|
||||||
// Build dictionaries from arrays
|
// Build dictionaries from arrays
|
||||||
let teamsById = Dictionary(uniqueKeysWithValues: AppDataProvider.shared.teams.map { ($0.id, $0) })
|
let teamsById = Dictionary(uniqueKeysWithValues: AppDataProvider.shared.teams.map { ($0.id, $0) })
|
||||||
let stadiumsById = Dictionary(uniqueKeysWithValues: AppDataProvider.shared.stadiums.map { ($0.id, $0) })
|
let stadiumsById = Dictionary(uniqueKeysWithValues: AppDataProvider.shared.stadiums.map { ($0.id, $0) })
|
||||||
|
|
||||||
|
// For gameFirst mode, derive date range from selected games
|
||||||
|
var games: [Game]
|
||||||
|
if viewModel.planningMode == .gameFirst && !viewModel.selectedGameIds.isEmpty {
|
||||||
|
// Fetch all games for the selected sports to find the must-see games
|
||||||
|
let allGames = try await AppDataProvider.shared.allGames(for: preferences.sports)
|
||||||
|
|
||||||
|
// Find the selected must-see games
|
||||||
|
let mustSeeGames = allGames.filter { viewModel.selectedGameIds.contains($0.id) }
|
||||||
|
|
||||||
|
if mustSeeGames.isEmpty {
|
||||||
|
planningError = "Could not find the selected games. Please try again."
|
||||||
|
showError = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive date range from must-see games (with buffer)
|
||||||
|
let gameDates = mustSeeGames.map { $0.dateTime }
|
||||||
|
let minDate = gameDates.min() ?? Date()
|
||||||
|
let maxDate = gameDates.max() ?? Date()
|
||||||
|
|
||||||
|
// Update preferences with derived date range
|
||||||
|
preferences = TripPreferences(
|
||||||
|
planningMode: preferences.planningMode,
|
||||||
|
startLocation: preferences.startLocation,
|
||||||
|
endLocation: preferences.endLocation,
|
||||||
|
sports: preferences.sports,
|
||||||
|
mustSeeGameIds: preferences.mustSeeGameIds,
|
||||||
|
startDate: Calendar.current.startOfDay(for: minDate),
|
||||||
|
endDate: Calendar.current.date(byAdding: .day, value: 1, to: maxDate) ?? maxDate,
|
||||||
|
mustStopLocations: preferences.mustStopLocations,
|
||||||
|
routePreference: preferences.routePreference,
|
||||||
|
allowRepeatCities: preferences.allowRepeatCities,
|
||||||
|
selectedRegions: preferences.selectedRegions,
|
||||||
|
followTeamId: preferences.followTeamId
|
||||||
|
)
|
||||||
|
|
||||||
|
// Use all games within the derived date range
|
||||||
|
games = allGames.filter {
|
||||||
|
$0.dateTime >= preferences.startDate && $0.dateTime <= preferences.endDate
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Standard mode: fetch games for date range
|
||||||
|
games = try await AppDataProvider.shared.filterGames(
|
||||||
|
sports: preferences.sports,
|
||||||
|
startDate: preferences.startDate,
|
||||||
|
endDate: preferences.endDate
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Build RichGame dictionary for display
|
// Build RichGame dictionary for display
|
||||||
var richGamesDict: [String: RichGame] = [:]
|
var richGamesDict: [String: RichGame] = [:]
|
||||||
for game in games {
|
for game in games {
|
||||||
@@ -171,15 +254,29 @@ struct TripWizardView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func buildPreferences() -> TripPreferences {
|
private func buildPreferences() -> TripPreferences {
|
||||||
TripPreferences(
|
// Determine which sports to use based on mode
|
||||||
|
let sports: Set<Sport>
|
||||||
|
if viewModel.planningMode == .gameFirst {
|
||||||
|
sports = viewModel.gamePickerSports
|
||||||
|
} else if viewModel.planningMode == .followTeam, let sport = viewModel.teamPickerSport {
|
||||||
|
sports = [sport]
|
||||||
|
} else {
|
||||||
|
sports = viewModel.selectedSports
|
||||||
|
}
|
||||||
|
|
||||||
|
return TripPreferences(
|
||||||
planningMode: viewModel.planningMode ?? .dateRange,
|
planningMode: viewModel.planningMode ?? .dateRange,
|
||||||
sports: viewModel.selectedSports,
|
startLocation: viewModel.startLocation,
|
||||||
|
endLocation: viewModel.endLocation,
|
||||||
|
sports: sports,
|
||||||
|
mustSeeGameIds: viewModel.selectedGameIds,
|
||||||
startDate: viewModel.startDate,
|
startDate: viewModel.startDate,
|
||||||
endDate: viewModel.endDate,
|
endDate: viewModel.endDate,
|
||||||
mustStopLocations: viewModel.mustStopLocations,
|
mustStopLocations: viewModel.mustStopLocations,
|
||||||
routePreference: viewModel.routePreference,
|
routePreference: viewModel.routePreference,
|
||||||
allowRepeatCities: viewModel.allowRepeatCities,
|
allowRepeatCities: viewModel.allowRepeatCities,
|
||||||
selectedRegions: viewModel.selectedRegions
|
selectedRegions: viewModel.selectedRegions,
|
||||||
|
followTeamId: viewModel.selectedTeamId
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user