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:
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()
|
||||
}
|
||||
Reference in New Issue
Block a user