Files
Sportstime/SportsTime/Features/Trip/Views/Wizard/Steps/GamePickerStep.swift
Trey t aa34c6585a 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>
2026-01-14 22:21:57 -06:00

525 lines
20 KiB
Swift

//
// 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()
}