// // 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 @Binding var selectedTeamIds: Set @Binding var selectedGameIds: Set @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)) if let value = value { HStack(spacing: Theme.Spacing.sm) { Button { if isEnabled { onTap() } } label: { HStack { Image(systemName: icon) .foregroundStyle(isEnabled ? Theme.warmOrange : Theme.textMuted(colorScheme)) .accessibilityHidden(true) Text(value) .font(.subheadline) .foregroundStyle(Theme.textPrimary(colorScheme)) .lineLimit(1) Spacer() } .contentShape(Rectangle()) } .buttonStyle(.plain) .disabled(!isEnabled) Button(action: onClear) { Image(systemName: "xmark.circle.fill") .foregroundStyle(Theme.textMuted(colorScheme)) } .buttonStyle(.plain) .minimumHitTarget() .accessibilityLabel("Clear \(label.lowercased()) selection") } .padding(Theme.Spacing.md) .background(Theme.cardBackgroundElevated(colorScheme)) .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) .overlay( RoundedRectangle(cornerRadius: Theme.CornerRadius.medium) .stroke(Theme.warmOrange, lineWidth: 2) ) .opacity(isEnabled ? 1 : 0.5) } else { Button { if isEnabled { onTap() } } label: { HStack { Image(systemName: icon) .foregroundStyle(isEnabled ? Theme.warmOrange : Theme.textMuted(colorScheme)) .accessibilityHidden(true) Text(placeholder) .font(.subheadline) .foregroundStyle(Theme.textMuted(colorScheme)) Spacer() Image(systemName: "chevron.right") .font(.caption) .foregroundStyle(Theme.textMuted(colorScheme)) .accessibilityHidden(true) } .padding(Theme.Spacing.md) .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) ) .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) .accessibilityHidden(true) 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)) } .minimumHitTarget() .accessibilityLabel("Remove \(game.matchupDescription)") } .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) .accessibilityHidden(true) 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 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) .accessibilityHidden(true) } else { Image(systemName: "circle") .foregroundStyle(Theme.textMuted(colorScheme)) .accessibilityHidden(true) } } .padding(.vertical, Theme.Spacing.xs) .contentShape(Rectangle()) } .buttonStyle(.plain) .accessibilityAddTraits(selectedSports.contains(sport) ? .isSelected : []) } } .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 @Binding var selectedTeamIds: Set 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) .accessibilityHidden(true) } else { Image(systemName: "circle") .foregroundStyle(Theme.textMuted(colorScheme)) .accessibilityHidden(true) } } .padding(.vertical, Theme.Spacing.xs) .contentShape(Rectangle()) } .buttonStyle(.plain) .accessibilityAddTraits(selectedTeamIds.contains(team.id) ? .isSelected : []) } } 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 @Binding var selectedGameIds: Set @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) .accessibilityHidden(true) } else { Image(systemName: "circle") .foregroundStyle(Theme.textMuted(colorScheme)) .accessibilityHidden(true) } } .padding(.vertical, Theme.Spacing.xs) .contentShape(Rectangle()) .accessibilityElement(children: .combine) } .buttonStyle(.plain) .accessibilityAddTraits(selectedGameIds.contains(game.id) ? .isSelected : []) } } 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() }