Two bugs fixed in "By Games" trip planning mode: 1. Calendar navigation: DateRangePicker now navigates to the selected game's month when startDate changes externally, instead of staying on the current month. 2. Date range calculation: Fixed race condition where date range was calculated before games were loaded. Now updateDateRangeForSelectedGames() is called after loadSummaryGames() completes. 3. Bonus games: planTrip() now uses the UI-selected 7-day date range instead of overriding it with just the anchor game dates. This allows ScenarioBPlanner to find additional games within the trip window. Added regression tests to verify gameFirst mode includes bonus games. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
624 lines
24 KiB
Swift
624 lines
24 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>
|
|
@Binding var startDate: Date
|
|
@Binding var endDate: Date
|
|
|
|
@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
|
|
|
|
// Date Range Section - shown when games are selected
|
|
dateRangeSection
|
|
}
|
|
}
|
|
.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))
|
|
}
|
|
// Update date range after games are loaded (not before)
|
|
await updateDateRangeForSelectedGames()
|
|
}
|
|
|
|
// MARK: - Date Range Section
|
|
|
|
private var dateRangeSection: some View {
|
|
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
|
HStack {
|
|
Image(systemName: "calendar")
|
|
.foregroundStyle(Theme.warmOrange)
|
|
Text("Trip Date Range")
|
|
.font(.subheadline)
|
|
.fontWeight(.medium)
|
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
|
|
|
Spacer()
|
|
|
|
// Show auto-calculated indicator
|
|
Text("Auto-adjusted")
|
|
.font(.caption2)
|
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 4)
|
|
.background(Theme.cardBackgroundElevated(colorScheme))
|
|
.clipShape(Capsule())
|
|
}
|
|
|
|
DateRangePicker(startDate: $startDate, endDate: $endDate)
|
|
|
|
// Game date markers legend
|
|
if !summaryGames.isEmpty {
|
|
let selectedGamesWithDates = summaryGames.filter { selectedGameIds.contains($0.id) }
|
|
if !selectedGamesWithDates.isEmpty {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text("Game dates:")
|
|
.font(.caption2)
|
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
|
|
|
ForEach(selectedGamesWithDates.sorted { $0.game.dateTime < $1.game.dateTime }) { game in
|
|
HStack(spacing: 6) {
|
|
Circle()
|
|
.fill(game.game.sport.themeColor)
|
|
.frame(width: 6, height: 6)
|
|
Text("\(game.matchupDescription) - \(game.game.dateTime, style: .date)")
|
|
.font(.caption2)
|
|
.foregroundStyle(Theme.textSecondary(colorScheme))
|
|
}
|
|
}
|
|
}
|
|
.padding(.top, Theme.Spacing.xs)
|
|
}
|
|
}
|
|
}
|
|
.padding(Theme.Spacing.md)
|
|
.background(Theme.cardBackgroundElevated(colorScheme))
|
|
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
|
|
}
|
|
|
|
/// Updates the date range based on selected games
|
|
/// - Single game: 7-day span centered on game (position 4 of 7, so game is day 4)
|
|
/// - Multiple games: range from earliest to latest with 1-day buffer
|
|
private func updateDateRangeForSelectedGames() async {
|
|
let selectedGames = summaryGames.filter { selectedGameIds.contains($0.id) }
|
|
|
|
guard !selectedGames.isEmpty else { return }
|
|
|
|
let gameDates = selectedGames.map { $0.game.dateTime }.sorted()
|
|
let calendar = Calendar.current
|
|
|
|
await MainActor.run {
|
|
if gameDates.count == 1 {
|
|
// Single game: 7-day span centered on game
|
|
// Position 4 of 7 means: 3 days before, game day, 3 days after
|
|
let gameDate = gameDates[0]
|
|
let newStart = calendar.date(byAdding: .day, value: -3, to: gameDate) ?? gameDate
|
|
let newEnd = calendar.date(byAdding: .day, value: 3, to: gameDate) ?? gameDate
|
|
startDate = calendar.startOfDay(for: newStart)
|
|
endDate = calendar.startOfDay(for: newEnd)
|
|
} else {
|
|
// Multiple games: span from first to last with 1-day buffer
|
|
let firstGameDate = gameDates.first!
|
|
let lastGameDate = gameDates.last!
|
|
let newStart = calendar.date(byAdding: .day, value: -1, to: firstGameDate) ?? firstGameDate
|
|
let newEnd = calendar.date(byAdding: .day, value: 1, to: lastGameDate) ?? lastGameDate
|
|
startDate = calendar.startOfDay(for: newStart)
|
|
endDate = calendar.startOfDay(for: newEnd)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.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)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.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)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.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([]),
|
|
startDate: .constant(Date()),
|
|
endDate: .constant(Date().addingTimeInterval(86400 * 7))
|
|
)
|
|
.padding()
|
|
.themedBackground()
|
|
}
|