fix(trip): redesign By Games mode with hierarchical calendar picker

Replace navigation-based team→games flow with expandable Sport→Team→Date
hierarchy. Games now grouped by date under each team with inline selection.
Also fixed game loading to always fetch 90-day browsing window.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-01-10 17:31:16 -06:00
parent 44952140c8
commit 6db0bdefcd
2 changed files with 414 additions and 374 deletions

View File

@@ -408,10 +408,8 @@ struct TripCreationView: View {
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, Theme.Spacing.md)
.task(id: viewModel.selectedSports) {
// Re-run when sports selection changes
if viewModel.availableGames.isEmpty {
await viewModel.loadGamesForBrowsing()
}
// Always load 90-day browsing window for gameFirst mode
await viewModel.loadGamesForBrowsing()
}
} else {
Button {
@@ -822,93 +820,99 @@ extension TripCreationViewModel.ViewState {
}
}
// MARK: - Game Picker Sheet (Team-based selection)
// MARK: - Game Picker Sheet (Calendar view: Sport Team Date)
struct GamePickerSheet: View {
let games: [RichGame]
@Binding var selectedIds: Set<UUID>
@Environment(\.dismiss) private var dismiss
@Environment(\.colorScheme) private var colorScheme
// Group games by team (both home and away)
private var teamsList: [TeamWithGames] {
var teamsDict: [UUID: TeamWithGames] = [:]
@State private var expandedSports: Set<Sport> = []
@State private var expandedTeams: Set<UUID> = []
// Group games by Sport Team (home team only to avoid duplicates)
private var gamesBySport: [Sport: [TeamWithGames]] {
var result: [Sport: [UUID: TeamWithGames]] = [:]
for game in games {
// Add to home team
if var teamData = teamsDict[game.homeTeam.id] {
teamData.games.append(game)
teamsDict[game.homeTeam.id] = teamData
} else {
teamsDict[game.homeTeam.id] = TeamWithGames(
team: game.homeTeam,
sport: game.game.sport,
games: [game]
)
let sport = game.game.sport
let team = game.homeTeam
if result[sport] == nil {
result[sport] = [:]
}
// Add to away team
if var teamData = teamsDict[game.awayTeam.id] {
if var teamData = result[sport]?[team.id] {
teamData.games.append(game)
teamsDict[game.awayTeam.id] = teamData
result[sport]?[team.id] = teamData
} else {
teamsDict[game.awayTeam.id] = TeamWithGames(
team: game.awayTeam,
sport: game.game.sport,
result[sport]?[team.id] = TeamWithGames(
team: team,
sport: sport,
games: [game]
)
}
}
return teamsDict.values
.sorted { $0.team.name < $1.team.name }
// Convert to sorted arrays
var sortedResult: [Sport: [TeamWithGames]] = [:]
for (sport, teamsDict) in result {
sortedResult[sport] = teamsDict.values.sorted { $0.team.name < $1.team.name }
}
return sortedResult
}
private var teamsBySport: [(sport: Sport, teams: [TeamWithGames])] {
let grouped = Dictionary(grouping: teamsList) { $0.sport }
return Sport.supported
.filter { grouped[$0] != nil }
.map { sport in
(sport, grouped[sport]!.sorted { $0.team.name < $1.team.name })
}
private var sortedSports: [Sport] {
Sport.supported.filter { gamesBySport[$0] != nil }
}
private var selectedGamesCount: Int {
selectedIds.count
}
private func selectedCountForSport(_ sport: Sport) -> Int {
guard let teams = gamesBySport[sport] else { return 0 }
return teams.flatMap { $0.games }.filter { selectedIds.contains($0.id) }.count
}
private func selectedCountForTeam(_ teamData: TeamWithGames) -> Int {
teamData.games.filter { selectedIds.contains($0.id) }.count
}
var body: some View {
NavigationStack {
List {
// Selected games summary
if !selectedIds.isEmpty {
Section {
ScrollView {
LazyVStack(spacing: 0) {
// Selected games summary
if !selectedIds.isEmpty {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
Text("\(selectedGamesCount) game(s) selected")
.fontWeight(.medium)
.font(.system(size: 15, weight: .semibold))
Spacer()
}
.padding(Theme.Spacing.md)
.background(Theme.cardBackground(colorScheme))
}
}
// Teams by sport
ForEach(teamsBySport, id: \.sport.id) { sportGroup in
Section(sportGroup.sport.rawValue) {
ForEach(sportGroup.teams) { teamData in
NavigationLink {
TeamGamesView(
teamData: teamData,
selectedIds: $selectedIds
)
} label: {
TeamRow(teamData: teamData, selectedIds: selectedIds)
}
}
// Sport sections
ForEach(sortedSports) { sport in
SportSection(
sport: sport,
teams: gamesBySport[sport] ?? [],
selectedIds: $selectedIds,
expandedSports: $expandedSports,
expandedTeams: $expandedTeams,
selectedCount: selectedCountForSport(sport)
)
}
}
}
.navigationTitle("Select Teams")
.themedBackground()
.navigationTitle("Select Games")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
if !selectedIds.isEmpty {
@@ -922,12 +926,258 @@ struct GamePickerSheet: View {
Button("Done") {
dismiss()
}
.fontWeight(.semibold)
}
}
}
}
}
// MARK: - Sport Section
struct SportSection: View {
let sport: Sport
let teams: [TeamWithGames]
@Binding var selectedIds: Set<UUID>
@Binding var expandedSports: Set<Sport>
@Binding var expandedTeams: Set<UUID>
let selectedCount: Int
@Environment(\.colorScheme) private var colorScheme
private var isExpanded: Bool {
expandedSports.contains(sport)
}
var body: some View {
VStack(spacing: 0) {
// Sport header
Button {
withAnimation(.easeInOut(duration: 0.2)) {
if isExpanded {
expandedSports.remove(sport)
} else {
expandedSports.insert(sport)
}
}
} label: {
HStack(spacing: Theme.Spacing.sm) {
Image(systemName: sport.iconName)
.font(.system(size: 20))
.foregroundStyle(sport.themeColor)
.frame(width: 32)
Text(sport.rawValue)
.font(.system(size: 17, weight: .bold))
.foregroundStyle(Theme.textPrimary(colorScheme))
Text("\(teams.flatMap { $0.games }.count) games")
.font(.system(size: 13))
.foregroundStyle(Theme.textMuted(colorScheme))
Spacer()
if selectedCount > 0 {
Text("\(selectedCount)")
.font(.system(size: 12, weight: .bold))
.foregroundStyle(.white)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(sport.themeColor)
.clipShape(Capsule())
}
Image(systemName: "chevron.right")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(Theme.textMuted(colorScheme))
.rotationEffect(.degrees(isExpanded ? 90 : 0))
}
.padding(Theme.Spacing.md)
.background(Theme.cardBackground(colorScheme))
}
.buttonStyle(.plain)
// Teams list (when expanded)
if isExpanded {
VStack(spacing: 0) {
ForEach(teams) { teamData in
TeamSection(
teamData: teamData,
selectedIds: $selectedIds,
expandedTeams: $expandedTeams
)
}
}
.padding(.leading, Theme.Spacing.lg)
}
Divider()
.overlay(Theme.surfaceGlow(colorScheme))
}
}
}
// MARK: - Team Section
struct TeamSection: View {
let teamData: TeamWithGames
@Binding var selectedIds: Set<UUID>
@Binding var expandedTeams: Set<UUID>
@Environment(\.colorScheme) private var colorScheme
private var isExpanded: Bool {
expandedTeams.contains(teamData.id)
}
private var selectedCount: Int {
teamData.games.filter { selectedIds.contains($0.id) }.count
}
// Group games by date
private var gamesByDate: [(date: String, games: [RichGame])] {
let grouped = Dictionary(grouping: teamData.sortedGames) { game in
game.game.formattedDate
}
return grouped.sorted { $0.value.first!.game.dateTime < $1.value.first!.game.dateTime }
.map { (date: $0.key, games: $0.value) }
}
var body: some View {
VStack(spacing: 0) {
// Team header
Button {
withAnimation(.easeInOut(duration: 0.2)) {
if isExpanded {
expandedTeams.remove(teamData.id)
} else {
expandedTeams.insert(teamData.id)
}
}
} label: {
HStack(spacing: Theme.Spacing.sm) {
// Team color
if let colorHex = teamData.team.primaryColor {
Circle()
.fill(Color(hex: colorHex))
.frame(width: 10, height: 10)
}
Text("\(teamData.team.city) \(teamData.team.name)")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(Theme.textPrimary(colorScheme))
Text("\(teamData.games.count)")
.font(.system(size: 12))
.foregroundStyle(Theme.textMuted(colorScheme))
Spacer()
if selectedCount > 0 {
Text("\(selectedCount)")
.font(.system(size: 11, weight: .bold))
.foregroundStyle(.white)
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(Theme.warmOrange)
.clipShape(Capsule())
}
Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(Theme.textMuted(colorScheme))
.rotationEffect(.degrees(isExpanded ? 90 : 0))
}
.padding(.vertical, Theme.Spacing.sm)
.padding(.horizontal, Theme.Spacing.md)
.background(Theme.cardBackgroundElevated(colorScheme))
}
.buttonStyle(.plain)
// Games grouped by date (when expanded)
if isExpanded {
VStack(spacing: 0) {
ForEach(gamesByDate, id: \.date) { dateGroup in
VStack(alignment: .leading, spacing: 0) {
// Date header
Text(dateGroup.date)
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(Theme.warmOrange)
.padding(.horizontal, Theme.Spacing.md)
.padding(.top, Theme.Spacing.sm)
.padding(.bottom, Theme.Spacing.xs)
// Games on this date
ForEach(dateGroup.games) { game in
GameCalendarRow(
game: game,
isSelected: selectedIds.contains(game.id),
onTap: {
if selectedIds.contains(game.id) {
selectedIds.remove(game.id)
} else {
selectedIds.insert(game.id)
}
}
)
}
}
}
}
.padding(.leading, Theme.Spacing.md)
.background(Theme.cardBackgroundElevated(colorScheme).opacity(0.5))
}
}
}
}
// MARK: - Game Calendar Row
struct GameCalendarRow: View {
let game: RichGame
let isSelected: Bool
let onTap: () -> Void
@Environment(\.colorScheme) private var colorScheme
var body: some View {
Button(action: onTap) {
HStack(spacing: Theme.Spacing.sm) {
// Selection indicator
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
.font(.system(size: 22))
.foregroundStyle(isSelected ? Theme.warmOrange : Theme.textMuted(colorScheme))
VStack(alignment: .leading, spacing: 2) {
Text("vs \(game.awayTeam.name)")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(Theme.textPrimary(colorScheme))
HStack(spacing: Theme.Spacing.xs) {
Text(game.game.gameTime)
.font(.system(size: 12))
.foregroundStyle(Theme.textSecondary(colorScheme))
Text("")
.foregroundStyle(Theme.textMuted(colorScheme))
Text(game.stadium.name)
.font(.system(size: 12))
.foregroundStyle(Theme.textMuted(colorScheme))
.lineLimit(1)
}
}
Spacer()
}
.padding(.vertical, Theme.Spacing.sm)
.padding(.horizontal, Theme.Spacing.md)
.background(isSelected ? Theme.warmOrange.opacity(0.1) : Color.clear)
}
.buttonStyle(.plain)
}
}
// MARK: - Team With Games Model
struct TeamWithGames: Identifiable {
@@ -942,151 +1192,6 @@ struct TeamWithGames: Identifiable {
}
}
// MARK: - Team Row
struct TeamRow: View {
let teamData: TeamWithGames
let selectedIds: Set<UUID>
private var selectedCount: Int {
teamData.games.filter { selectedIds.contains($0.id) }.count
}
var body: some View {
HStack(spacing: 12) {
// Team color indicator
if let colorHex = teamData.team.primaryColor {
Circle()
.fill(Color(hex: colorHex))
.frame(width: 12, height: 12)
}
VStack(alignment: .leading, spacing: 2) {
Text("\(teamData.team.city) \(teamData.team.name)")
.font(.subheadline)
.fontWeight(.medium)
Text("\(teamData.games.count) game(s) available")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if selectedCount > 0 {
Text("\(selectedCount)")
.font(.caption)
.fontWeight(.bold)
.foregroundStyle(.white)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(.blue)
.clipShape(Capsule())
}
}
}
}
// MARK: - Team Games View
struct TeamGamesView: View {
let teamData: TeamWithGames
@Binding var selectedIds: Set<UUID>
var body: some View {
List {
ForEach(teamData.sortedGames) { game in
GamePickerRow(game: game, isSelected: selectedIds.contains(game.id)) {
if selectedIds.contains(game.id) {
selectedIds.remove(game.id)
} else {
selectedIds.insert(game.id)
}
}
}
}
.navigationTitle("\(teamData.team.city) \(teamData.team.name)")
.navigationBarTitleDisplayMode(.inline)
}
}
struct GamePickerRow: View {
let game: RichGame
let isSelected: Bool
let onTap: () -> Void
@Environment(\.colorScheme) private var colorScheme
var body: some View {
Button(action: onTap) {
HStack(spacing: 12) {
// Sport color bar
SportColorBar(sport: game.game.sport)
VStack(alignment: .leading, spacing: 4) {
Text(game.matchupDescription)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(Theme.textPrimary(colorScheme))
Text(game.venueDescription)
.font(.caption)
.foregroundStyle(Theme.textSecondary(colorScheme))
Text("\(game.game.formattedDate)\(game.game.gameTime)")
.font(.caption2)
.foregroundStyle(Theme.textMuted(colorScheme))
}
Spacer()
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
.foregroundStyle(isSelected ? Theme.warmOrange : Theme.textMuted(colorScheme))
.font(.title2)
}
}
.buttonStyle(.plain)
}
}
struct GameSelectRow: View {
let game: RichGame
let isSelected: Bool
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
HStack(spacing: 12) {
// Sport icon
Image(systemName: game.game.sport.iconName)
.font(.title3)
.foregroundStyle(isSelected ? .blue : .secondary)
.frame(width: 24)
VStack(alignment: .leading, spacing: 2) {
Text("\(game.awayTeam.abbreviation) @ \(game.homeTeam.abbreviation)")
.font(.subheadline)
.fontWeight(.medium)
Text("\(game.game.formattedDate)\(game.game.gameTime)")
.font(.caption)
.foregroundStyle(.secondary)
Text(game.stadium.city)
.font(.caption2)
.foregroundStyle(.tertiary)
}
Spacer()
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
.foregroundStyle(isSelected ? .blue : .gray.opacity(0.5))
.font(.title3)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
// MARK: - Location Search Sheet
struct LocationSearchSheet: View {