perf: lazy hierarchical loading for game picker
Replace upfront loading of all games with lazy Sport → Team → Game hierarchy. Games are now fetched per-team when expanded and cached to prevent re-fetching. Also removes pagination workaround and pre-computes groupings in ScheduleViewModel to avoid per-render work. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -219,6 +219,37 @@ final class AppDataProvider: ObservableObject {
|
|||||||
}
|
}
|
||||||
return RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium)
|
return RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get all games for a specific team (home or away) - for lazy loading in game picker
|
||||||
|
func gamesForTeam(teamId: String) async throws -> [RichGame] {
|
||||||
|
guard let context = modelContext else {
|
||||||
|
throw DataProviderError.contextNotConfigured
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch all non-deprecated games (predicate with captured vars causes type-check timeout)
|
||||||
|
let descriptor = FetchDescriptor<CanonicalGame>(
|
||||||
|
predicate: #Predicate<CanonicalGame> { $0.deprecatedAt == nil },
|
||||||
|
sortBy: [SortDescriptor(\.dateTime)]
|
||||||
|
)
|
||||||
|
|
||||||
|
let allCanonical: [CanonicalGame] = try context.fetch(descriptor)
|
||||||
|
|
||||||
|
// Filter by team in Swift (fast for ~5K games)
|
||||||
|
var teamGames: [RichGame] = []
|
||||||
|
for canonical in allCanonical {
|
||||||
|
guard canonical.homeTeamCanonicalId == teamId || canonical.awayTeamCanonicalId == teamId else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
let game = canonical.toDomain()
|
||||||
|
guard let homeTeam = teamsById[game.homeTeamId],
|
||||||
|
let awayTeam = teamsById[game.awayTeamId],
|
||||||
|
let stadium = stadiumsById[game.stadiumId] else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
teamGames.append(RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium))
|
||||||
|
}
|
||||||
|
return teamGames
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Errors
|
// MARK: - Errors
|
||||||
|
|||||||
@@ -26,88 +26,18 @@ final class ScheduleViewModel {
|
|||||||
|
|
||||||
private let dataProvider = AppDataProvider.shared
|
private let dataProvider = AppDataProvider.shared
|
||||||
|
|
||||||
// MARK: - Pagination
|
// MARK: - Pre-computed Groupings (avoid computed property overhead)
|
||||||
|
|
||||||
private let pageSize = 50
|
/// Games grouped by sport - pre-computed to avoid re-grouping on every render
|
||||||
private(set) var displayedGames: [RichGame] = []
|
private(set) var gamesBySport: [(sport: Sport, games: [RichGame])] = []
|
||||||
private var currentPage = 0
|
|
||||||
private var allFilteredGames: [RichGame] = []
|
|
||||||
|
|
||||||
var hasMoreGames: Bool {
|
/// All games matching current filters (before any display limiting)
|
||||||
displayedGames.count < allFilteredGames.count
|
private var filteredGames: [RichGame] = []
|
||||||
}
|
|
||||||
|
|
||||||
/// The last game in the displayed list, used for infinite scroll detection
|
|
||||||
var lastDisplayedGame: RichGame? {
|
|
||||||
displayedGames.last
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Computed Properties
|
|
||||||
|
|
||||||
var filteredGames: [RichGame] {
|
|
||||||
guard !searchText.isEmpty else { return games }
|
|
||||||
|
|
||||||
let query = searchText.lowercased()
|
|
||||||
return games.filter { game in
|
|
||||||
game.homeTeam.name.lowercased().contains(query) ||
|
|
||||||
game.homeTeam.city.lowercased().contains(query) ||
|
|
||||||
game.awayTeam.name.lowercased().contains(query) ||
|
|
||||||
game.awayTeam.city.lowercased().contains(query) ||
|
|
||||||
game.stadium.name.lowercased().contains(query) ||
|
|
||||||
game.stadium.city.lowercased().contains(query)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var gamesByDate: [(date: Date, games: [RichGame])] {
|
|
||||||
let calendar = Calendar.current
|
|
||||||
let grouped = Dictionary(grouping: filteredGames) { game in
|
|
||||||
calendar.startOfDay(for: game.game.dateTime)
|
|
||||||
}
|
|
||||||
return grouped.sorted { $0.key < $1.key }.map { ($0.key, $0.value) }
|
|
||||||
}
|
|
||||||
|
|
||||||
var gamesBySport: [(sport: Sport, games: [RichGame])] {
|
|
||||||
let grouped = Dictionary(grouping: displayedGames) { game in
|
|
||||||
game.game.sport
|
|
||||||
}
|
|
||||||
// Sort by sport order (use allCases index for consistent ordering)
|
|
||||||
// Within each sport, games are sorted by date
|
|
||||||
return grouped
|
|
||||||
.sorted { lhs, rhs in
|
|
||||||
let lhsIndex = Sport.allCases.firstIndex(of: lhs.key) ?? 0
|
|
||||||
let rhsIndex = Sport.allCases.firstIndex(of: rhs.key) ?? 0
|
|
||||||
return lhsIndex < rhsIndex
|
|
||||||
}
|
|
||||||
.map { (sport: $0.key, games: $0.value.sorted { $0.game.dateTime < $1.game.dateTime }) }
|
|
||||||
}
|
|
||||||
|
|
||||||
var hasFilters: Bool {
|
var hasFilters: Bool {
|
||||||
selectedSports.count < Sport.supported.count || !searchText.isEmpty
|
selectedSports.count < Sport.supported.count || !searchText.isEmpty
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Pagination Actions
|
|
||||||
|
|
||||||
/// Updates filtered games list based on current search text and resets pagination
|
|
||||||
func updateFilteredGames() {
|
|
||||||
allFilteredGames = filteredGames
|
|
||||||
loadInitialGames()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Loads the first page of games
|
|
||||||
func loadInitialGames() {
|
|
||||||
currentPage = 0
|
|
||||||
displayedGames = Array(allFilteredGames.prefix(pageSize))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Loads more games when scrolling to the bottom
|
|
||||||
func loadMoreGames() {
|
|
||||||
guard hasMoreGames else { return }
|
|
||||||
currentPage += 1
|
|
||||||
let start = currentPage * pageSize
|
|
||||||
let end = min(start + pageSize, allFilteredGames.count)
|
|
||||||
displayedGames.append(contentsOf: allFilteredGames[start..<end])
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Actions
|
// MARK: - Actions
|
||||||
|
|
||||||
func loadGames() async {
|
func loadGames() async {
|
||||||
@@ -186,4 +116,34 @@ final class ScheduleViewModel {
|
|||||||
await loadGames()
|
await loadGames()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Filtering & Grouping (pre-computed, not computed properties)
|
||||||
|
|
||||||
|
/// Recomputes filtered games and groupings based on current search text
|
||||||
|
func updateFilteredGames() {
|
||||||
|
// Step 1: Filter by search text
|
||||||
|
if searchText.isEmpty {
|
||||||
|
filteredGames = games
|
||||||
|
} else {
|
||||||
|
let query = searchText.lowercased()
|
||||||
|
filteredGames = games.filter { game in
|
||||||
|
game.homeTeam.name.lowercased().contains(query) ||
|
||||||
|
game.homeTeam.city.lowercased().contains(query) ||
|
||||||
|
game.awayTeam.name.lowercased().contains(query) ||
|
||||||
|
game.awayTeam.city.lowercased().contains(query) ||
|
||||||
|
game.stadium.name.lowercased().contains(query) ||
|
||||||
|
game.stadium.city.lowercased().contains(query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Pre-compute grouping by sport (done once, not per-render)
|
||||||
|
let grouped = Dictionary(grouping: filteredGames) { $0.game.sport }
|
||||||
|
gamesBySport = grouped
|
||||||
|
.sorted { lhs, rhs in
|
||||||
|
let lhsIndex = Sport.allCases.firstIndex(of: lhs.key) ?? 0
|
||||||
|
let rhsIndex = Sport.allCases.firstIndex(of: rhs.key) ?? 0
|
||||||
|
return lhsIndex < rhsIndex
|
||||||
|
}
|
||||||
|
.map { (sport: $0.key, games: $0.value.sorted { $0.game.dateTime < $1.game.dateTime }) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,13 +10,17 @@ struct ScheduleListView: View {
|
|||||||
@State private var viewModel = ScheduleViewModel()
|
@State private var viewModel = ScheduleViewModel()
|
||||||
@State private var showDatePicker = false
|
@State private var showDatePicker = false
|
||||||
|
|
||||||
|
private var allGames: [RichGame] {
|
||||||
|
viewModel.gamesBySport.flatMap(\.games)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
if viewModel.isLoading && viewModel.displayedGames.isEmpty {
|
if viewModel.isLoading && allGames.isEmpty {
|
||||||
loadingView
|
loadingView
|
||||||
} else if let errorMessage = viewModel.errorMessage {
|
} else if let errorMessage = viewModel.errorMessage {
|
||||||
errorView(message: errorMessage)
|
errorView(message: errorMessage)
|
||||||
} else if viewModel.displayedGames.isEmpty {
|
} else if allGames.isEmpty {
|
||||||
emptyView
|
emptyView
|
||||||
} else {
|
} else {
|
||||||
gamesList
|
gamesList
|
||||||
@@ -96,12 +100,6 @@ struct ScheduleListView: View {
|
|||||||
Section {
|
Section {
|
||||||
ForEach(sportGroup.games) { richGame in
|
ForEach(sportGroup.games) { richGame in
|
||||||
GameRowView(game: richGame, showDate: true, showLocation: true)
|
GameRowView(game: richGame, showDate: true, showLocation: true)
|
||||||
.onAppear {
|
|
||||||
// Trigger load more when the last game appears
|
|
||||||
if richGame.id == viewModel.lastDisplayedGame?.id {
|
|
||||||
viewModel.loadMoreGames()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} header: {
|
} header: {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
@@ -112,18 +110,6 @@ struct ScheduleListView: View {
|
|||||||
}
|
}
|
||||||
.listRowBackground(Theme.cardBackground(colorScheme))
|
.listRowBackground(Theme.cardBackground(colorScheme))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show loading indicator when more games are available
|
|
||||||
if viewModel.hasMoreGames {
|
|
||||||
Section {
|
|
||||||
HStack {
|
|
||||||
Spacer()
|
|
||||||
ProgressView()
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
.listRowBackground(Color.clear)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.listStyle(.plain)
|
.listStyle(.plain)
|
||||||
.scrollContentBackground(.hidden)
|
.scrollContentBackground(.hidden)
|
||||||
@@ -231,6 +217,14 @@ struct GameRowView: View {
|
|||||||
var showDate: Bool = false
|
var showDate: Bool = false
|
||||||
var showLocation: Bool = false
|
var showLocation: Bool = false
|
||||||
|
|
||||||
|
// Static formatter to avoid allocation per row (significant performance improvement)
|
||||||
|
private static let dateFormatter: DateFormatter = {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "EEE, MMM d"
|
||||||
|
return formatter
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Cache isToday check to avoid repeated Calendar calls
|
||||||
private var isToday: Bool {
|
private var isToday: Bool {
|
||||||
Calendar.current.isDateInToday(game.game.dateTime)
|
Calendar.current.isDateInToday(game.game.dateTime)
|
||||||
}
|
}
|
||||||
@@ -240,7 +234,7 @@ struct GameRowView: View {
|
|||||||
// Date (when grouped by sport)
|
// Date (when grouped by sport)
|
||||||
if showDate {
|
if showDate {
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
Text(formattedDate)
|
Text(Self.dateFormatter.string(from: game.game.dateTime))
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.fontWeight(.medium)
|
.fontWeight(.medium)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
@@ -300,12 +294,6 @@ struct GameRowView: View {
|
|||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
.listRowBackground(isToday ? Color.orange.opacity(0.1) : nil)
|
.listRowBackground(isToday ? Color.orange.opacity(0.1) : nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var formattedDate: String {
|
|
||||||
let formatter = DateFormatter()
|
|
||||||
formatter.dateFormat = "EEE, MMM d"
|
|
||||||
return formatter.string(from: game.game.dateTime)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Team Badge
|
// MARK: - Team Badge
|
||||||
|
|||||||
@@ -112,28 +112,6 @@ final class TripCreationViewModel {
|
|||||||
var availableGames: [RichGame] = []
|
var availableGames: [RichGame] = []
|
||||||
var isLoadingGames: Bool = false
|
var isLoadingGames: Bool = false
|
||||||
|
|
||||||
// MARK: - Game Pagination
|
|
||||||
private let gamePageSize = 50
|
|
||||||
var displayedAvailableGames: [RichGame] = []
|
|
||||||
private var currentGamePage = 0
|
|
||||||
|
|
||||||
var hasMoreAvailableGames: Bool {
|
|
||||||
displayedAvailableGames.count < availableGames.count
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadInitialAvailableGames() {
|
|
||||||
currentGamePage = 0
|
|
||||||
displayedAvailableGames = Array(availableGames.prefix(gamePageSize))
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadMoreAvailableGames() {
|
|
||||||
guard hasMoreAvailableGames else { return }
|
|
||||||
currentGamePage += 1
|
|
||||||
let start = currentGamePage * gamePageSize
|
|
||||||
let end = min(start + gamePageSize, availableGames.count)
|
|
||||||
displayedAvailableGames.append(contentsOf: availableGames[start..<end])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Travel
|
// Travel
|
||||||
var travelMode: TravelMode = .drive
|
var travelMode: TravelMode = .drive
|
||||||
var routePreference: RoutePreference = .balanced
|
var routePreference: RoutePreference = .balanced
|
||||||
@@ -298,9 +276,6 @@ final class TripCreationViewModel {
|
|||||||
}
|
}
|
||||||
return RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium)
|
return RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize paginated display
|
|
||||||
loadInitialAvailableGames()
|
|
||||||
} catch {
|
} catch {
|
||||||
viewState = .error("Failed to load schedule data: \(error.localizedDescription)")
|
viewState = .error("Failed to load schedule data: \(error.localizedDescription)")
|
||||||
}
|
}
|
||||||
@@ -526,9 +501,6 @@ final class TripCreationViewModel {
|
|||||||
return RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium)
|
return RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium)
|
||||||
}.sorted { $0.game.dateTime < $1.game.dateTime }
|
}.sorted { $0.game.dateTime < $1.game.dateTime }
|
||||||
|
|
||||||
// Initialize paginated display
|
|
||||||
loadInitialAvailableGames()
|
|
||||||
|
|
||||||
} catch {
|
} catch {
|
||||||
viewState = .error("Failed to load games: \(error.localizedDescription)")
|
viewState = .error("Failed to load games: \(error.localizedDescription)")
|
||||||
}
|
}
|
||||||
@@ -569,8 +541,6 @@ final class TripCreationViewModel {
|
|||||||
mustStopLocations = []
|
mustStopLocations = []
|
||||||
preferredCities = []
|
preferredCities = []
|
||||||
availableGames = []
|
availableGames = []
|
||||||
displayedAvailableGames = []
|
|
||||||
currentGamePage = 0
|
|
||||||
isLoadingGames = false
|
isLoadingGames = false
|
||||||
currentPreferences = nil
|
currentPreferences = nil
|
||||||
allowRepeatCities = true
|
allowRepeatCities = true
|
||||||
|
|||||||
@@ -114,11 +114,8 @@ struct TripCreationView: View {
|
|||||||
}
|
}
|
||||||
.sheet(isPresented: $showGamePicker) {
|
.sheet(isPresented: $showGamePicker) {
|
||||||
GamePickerSheet(
|
GamePickerSheet(
|
||||||
games: viewModel.displayedAvailableGames,
|
selectedSports: viewModel.selectedSports,
|
||||||
selectedIds: $viewModel.mustSeeGameIds,
|
selectedIds: $viewModel.mustSeeGameIds
|
||||||
hasMoreGames: viewModel.hasMoreAvailableGames,
|
|
||||||
totalGamesCount: viewModel.availableGames.count,
|
|
||||||
loadMoreGames: { viewModel.loadMoreAvailableGames() }
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showCityInput) {
|
.sheet(isPresented: $showCityInput) {
|
||||||
@@ -455,12 +452,6 @@ struct TripCreationView: View {
|
|||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||||
}
|
}
|
||||||
.onAppear {
|
|
||||||
// Ensure pagination is initialized when view appears
|
|
||||||
if viewModel.displayedAvailableGames.isEmpty && !viewModel.availableGames.isEmpty {
|
|
||||||
viewModel.loadInitialAvailableGames()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
@@ -954,68 +945,29 @@ extension TripCreationViewModel.ViewState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Game Picker Sheet (Calendar view: Sport → Team → Date)
|
// MARK: - Game Picker Sheet (Calendar view: Sport → Team → Date with lazy loading)
|
||||||
|
|
||||||
struct GamePickerSheet: View {
|
struct GamePickerSheet: View {
|
||||||
let games: [RichGame]
|
let selectedSports: Set<Sport>
|
||||||
@Binding var selectedIds: Set<String>
|
@Binding var selectedIds: Set<String>
|
||||||
let hasMoreGames: Bool
|
|
||||||
let totalGamesCount: Int
|
|
||||||
let loadMoreGames: () -> Void
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@Environment(\.colorScheme) private var colorScheme
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
|
||||||
@State private var expandedSports: Set<Sport> = []
|
@State private var expandedSports: Set<Sport> = []
|
||||||
@State private var expandedTeams: Set<String> = []
|
@State private var expandedTeams: Set<String> = []
|
||||||
|
@State private var gamesCache: [String: [RichGame]] = [:] // teamId -> games
|
||||||
|
@State private var loadingTeams: Set<String> = []
|
||||||
|
@State private var selectedGamesCache: [String: RichGame] = [:] // gameId -> game (for count display)
|
||||||
|
|
||||||
// Group games by Sport → Team (both home and away teams so browsing shows all team games)
|
private let dataProvider = AppDataProvider.shared
|
||||||
private var gamesBySport: [Sport: [TeamWithGames]] {
|
|
||||||
var result: [Sport: [String: TeamWithGames]] = [:]
|
|
||||||
|
|
||||||
for game in games {
|
// Get teams for a sport (from in-memory cache, no fetching needed)
|
||||||
let sport = game.game.sport
|
private func teamsForSport(_ sport: Sport) -> [Team] {
|
||||||
|
dataProvider.teams(for: sport).sorted { $0.name < $1.name }
|
||||||
if result[sport] == nil {
|
|
||||||
result[sport] = [:]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add game to home team's list
|
|
||||||
let homeTeam = game.homeTeam
|
|
||||||
if var teamData = result[sport]?[homeTeam.id] {
|
|
||||||
teamData.games.append(game)
|
|
||||||
result[sport]?[homeTeam.id] = teamData
|
|
||||||
} else {
|
|
||||||
result[sport]?[homeTeam.id] = TeamWithGames(
|
|
||||||
team: homeTeam,
|
|
||||||
sport: sport,
|
|
||||||
games: [game]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also add game to away team's list (so browsing by team shows all games)
|
|
||||||
let awayTeam = game.awayTeam
|
|
||||||
if var teamData = result[sport]?[awayTeam.id] {
|
|
||||||
teamData.games.append(game)
|
|
||||||
result[sport]?[awayTeam.id] = teamData
|
|
||||||
} else {
|
|
||||||
result[sport]?[awayTeam.id] = TeamWithGames(
|
|
||||||
team: awayTeam,
|
|
||||||
sport: sport,
|
|
||||||
games: [game]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 sortedSports: [Sport] {
|
private var sortedSports: [Sport] {
|
||||||
Sport.supported.filter { gamesBySport[$0] != nil }
|
Sport.supported.filter { selectedSports.contains($0) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private var selectedGamesCount: Int {
|
private var selectedGamesCount: Int {
|
||||||
@@ -1023,64 +975,47 @@ struct GamePickerSheet: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func selectedCountForSport(_ sport: Sport) -> Int {
|
private func selectedCountForSport(_ sport: Sport) -> Int {
|
||||||
guard let teams = gamesBySport[sport] else { return 0 }
|
let teamIds = Set(teamsForSport(sport).map { $0.id })
|
||||||
return teams.flatMap { $0.games }.filter { selectedIds.contains($0.id) }.count
|
return selectedGamesCache.values.filter { game in
|
||||||
|
teamIds.contains(game.homeTeam.id) || teamIds.contains(game.awayTeam.id)
|
||||||
|
}.count
|
||||||
}
|
}
|
||||||
|
|
||||||
private func selectedCountForTeam(_ teamData: TeamWithGames) -> Int {
|
private func selectedCountForTeam(_ teamId: String) -> Int {
|
||||||
teamData.games.filter { selectedIds.contains($0.id) }.count
|
guard let games = gamesCache[teamId] else { return 0 }
|
||||||
|
return games.filter { selectedIds.contains($0.id) }.count
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(spacing: 0) {
|
LazyVStack(spacing: 0) {
|
||||||
// Selected games summary
|
// Selected games summary (always visible to prevent layout jump)
|
||||||
if !selectedIds.isEmpty {
|
HStack {
|
||||||
HStack {
|
Image(systemName: selectedIds.isEmpty ? "circle" : "checkmark.circle.fill")
|
||||||
Image(systemName: "checkmark.circle.fill")
|
.foregroundStyle(selectedIds.isEmpty ? Theme.textMuted(colorScheme) : .green)
|
||||||
.foregroundStyle(.green)
|
Text("\(selectedGamesCount) game(s) selected")
|
||||||
Text("\(selectedGamesCount) game(s) selected")
|
.font(.subheadline)
|
||||||
.font(.subheadline)
|
.foregroundStyle(selectedIds.isEmpty ? Theme.textMuted(colorScheme) : Theme.textPrimary(colorScheme))
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
|
||||||
.padding(Theme.Spacing.md)
|
|
||||||
.background(Theme.cardBackground(colorScheme))
|
|
||||||
}
|
}
|
||||||
|
.padding(Theme.Spacing.md)
|
||||||
|
.background(Theme.cardBackground(colorScheme))
|
||||||
|
|
||||||
// Sport sections
|
// Sport sections
|
||||||
ForEach(sortedSports) { sport in
|
ForEach(sortedSports) { sport in
|
||||||
SportSection(
|
LazySportSection(
|
||||||
sport: sport,
|
sport: sport,
|
||||||
teams: gamesBySport[sport] ?? [],
|
teams: teamsForSport(sport),
|
||||||
selectedIds: $selectedIds,
|
selectedIds: $selectedIds,
|
||||||
expandedSports: $expandedSports,
|
expandedSports: $expandedSports,
|
||||||
expandedTeams: $expandedTeams,
|
expandedTeams: $expandedTeams,
|
||||||
|
gamesCache: $gamesCache,
|
||||||
|
loadingTeams: $loadingTeams,
|
||||||
|
selectedGamesCache: $selectedGamesCache,
|
||||||
selectedCount: selectedCountForSport(sport)
|
selectedCount: selectedCountForSport(sport)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load more indicator with infinite scroll trigger
|
|
||||||
if hasMoreGames {
|
|
||||||
Button {
|
|
||||||
loadMoreGames()
|
|
||||||
} label: {
|
|
||||||
HStack(spacing: Theme.Spacing.sm) {
|
|
||||||
ProgressView()
|
|
||||||
.tint(Theme.warmOrange)
|
|
||||||
Text("Load more games (\(games.count) of \(totalGamesCount))")
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(Theme.Spacing.md)
|
|
||||||
.background(Theme.cardBackground(colorScheme))
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.onAppear {
|
|
||||||
loadMoreGames()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.themedBackground()
|
.themedBackground()
|
||||||
@@ -1094,6 +1029,7 @@ struct GamePickerSheet: View {
|
|||||||
transaction.disablesAnimations = true
|
transaction.disablesAnimations = true
|
||||||
withTransaction(transaction) {
|
withTransaction(transaction) {
|
||||||
selectedIds.removeAll()
|
selectedIds.removeAll()
|
||||||
|
selectedGamesCache.removeAll()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.foregroundStyle(.red)
|
.foregroundStyle(.red)
|
||||||
@@ -1110,14 +1046,17 @@ struct GamePickerSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Sport Section
|
// MARK: - Lazy Sport Section (loads teams from memory, games loaded on-demand per team)
|
||||||
|
|
||||||
struct SportSection: View {
|
struct LazySportSection: View {
|
||||||
let sport: Sport
|
let sport: Sport
|
||||||
let teams: [TeamWithGames]
|
let teams: [Team]
|
||||||
@Binding var selectedIds: Set<String>
|
@Binding var selectedIds: Set<String>
|
||||||
@Binding var expandedSports: Set<Sport>
|
@Binding var expandedSports: Set<Sport>
|
||||||
@Binding var expandedTeams: Set<String>
|
@Binding var expandedTeams: Set<String>
|
||||||
|
@Binding var gamesCache: [String: [RichGame]]
|
||||||
|
@Binding var loadingTeams: Set<String>
|
||||||
|
@Binding var selectedGamesCache: [String: RichGame]
|
||||||
let selectedCount: Int
|
let selectedCount: Int
|
||||||
|
|
||||||
@Environment(\.colorScheme) private var colorScheme
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
@@ -1148,7 +1087,7 @@ struct SportSection: View {
|
|||||||
.font(.headline)
|
.font(.headline)
|
||||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||||
|
|
||||||
Text("\(teams.flatMap { $0.games }.count) games")
|
Text("\(teams.count) teams")
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||||
|
|
||||||
@@ -1177,11 +1116,15 @@ struct SportSection: View {
|
|||||||
// Teams list (when expanded)
|
// Teams list (when expanded)
|
||||||
if isExpanded {
|
if isExpanded {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
ForEach(teams) { teamData in
|
ForEach(teams) { team in
|
||||||
TeamSection(
|
LazyTeamSection(
|
||||||
teamData: teamData,
|
team: team,
|
||||||
|
sport: sport,
|
||||||
selectedIds: $selectedIds,
|
selectedIds: $selectedIds,
|
||||||
expandedTeams: $expandedTeams
|
expandedTeams: $expandedTeams,
|
||||||
|
gamesCache: $gamesCache,
|
||||||
|
loadingTeams: $loadingTeams,
|
||||||
|
selectedGamesCache: $selectedGamesCache
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1194,26 +1137,45 @@ struct SportSection: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Team Section
|
// MARK: - Lazy Team Section (loads games on-demand when expanded)
|
||||||
|
|
||||||
struct TeamSection: View {
|
struct LazyTeamSection: View {
|
||||||
let teamData: TeamWithGames
|
let team: Team
|
||||||
|
let sport: Sport
|
||||||
@Binding var selectedIds: Set<String>
|
@Binding var selectedIds: Set<String>
|
||||||
@Binding var expandedTeams: Set<String>
|
@Binding var expandedTeams: Set<String>
|
||||||
|
@Binding var gamesCache: [String: [RichGame]]
|
||||||
|
@Binding var loadingTeams: Set<String>
|
||||||
|
@Binding var selectedGamesCache: [String: RichGame]
|
||||||
|
|
||||||
@Environment(\.colorScheme) private var colorScheme
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
|
||||||
|
private let dataProvider = AppDataProvider.shared
|
||||||
|
|
||||||
private var isExpanded: Bool {
|
private var isExpanded: Bool {
|
||||||
expandedTeams.contains(teamData.id)
|
expandedTeams.contains(team.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isLoading: Bool {
|
||||||
|
loadingTeams.contains(team.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var games: [RichGame] {
|
||||||
|
gamesCache[team.id] ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
private var selectedCount: Int {
|
private var selectedCount: Int {
|
||||||
teamData.games.filter { selectedIds.contains($0.id) }.count
|
games.filter { selectedIds.contains($0.id) }.count
|
||||||
|
}
|
||||||
|
|
||||||
|
private var gamesCount: Int? {
|
||||||
|
gamesCache[team.id]?.count
|
||||||
}
|
}
|
||||||
|
|
||||||
// Group games by date
|
// Group games by date
|
||||||
private var gamesByDate: [(date: String, games: [RichGame])] {
|
private var gamesByDate: [(date: String, games: [RichGame])] {
|
||||||
let grouped = Dictionary(grouping: teamData.sortedGames) { game in
|
let sortedGames = games.sorted { $0.game.dateTime < $1.game.dateTime }
|
||||||
|
let grouped = Dictionary(grouping: sortedGames) { game in
|
||||||
game.game.formattedDate
|
game.game.formattedDate
|
||||||
}
|
}
|
||||||
return grouped.sorted { $0.value.first!.game.dateTime < $1.value.first!.game.dateTime }
|
return grouped.sorted { $0.value.first!.game.dateTime < $1.value.first!.game.dateTime }
|
||||||
@@ -1226,31 +1188,39 @@ struct TeamSection: View {
|
|||||||
Button {
|
Button {
|
||||||
withAnimation(.easeInOut(duration: 0.2)) {
|
withAnimation(.easeInOut(duration: 0.2)) {
|
||||||
if isExpanded {
|
if isExpanded {
|
||||||
expandedTeams.remove(teamData.id)
|
expandedTeams.remove(team.id)
|
||||||
} else {
|
} else {
|
||||||
expandedTeams.insert(teamData.id)
|
expandedTeams.insert(team.id)
|
||||||
|
// Load games if not cached
|
||||||
|
if gamesCache[team.id] == nil && !loadingTeams.contains(team.id) {
|
||||||
|
loadGames()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
HStack(spacing: Theme.Spacing.sm) {
|
HStack(spacing: Theme.Spacing.sm) {
|
||||||
// Team color
|
// Team color
|
||||||
if let colorHex = teamData.team.primaryColor {
|
if let colorHex = team.primaryColor {
|
||||||
Circle()
|
Circle()
|
||||||
.fill(Color(hex: colorHex))
|
.fill(Color(hex: colorHex))
|
||||||
.frame(width: 10, height: 10)
|
.frame(width: 10, height: 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
Text("\(teamData.team.city) \(teamData.team.name)")
|
Text("\(team.city) \(team.name)")
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||||
|
|
||||||
Text("\(teamData.games.count)")
|
if let count = gamesCount {
|
||||||
.font(.caption)
|
Text("\(count)")
|
||||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
.font(.caption)
|
||||||
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||||
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
if selectedCount > 0 {
|
if isLoading {
|
||||||
|
ThemedSpinnerCompact(size: 14)
|
||||||
|
} else if selectedCount > 0 {
|
||||||
Text("\(selectedCount)")
|
Text("\(selectedCount)")
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
@@ -1273,41 +1243,76 @@ struct TeamSection: View {
|
|||||||
|
|
||||||
// Games grouped by date (when expanded)
|
// Games grouped by date (when expanded)
|
||||||
if isExpanded {
|
if isExpanded {
|
||||||
VStack(spacing: 0) {
|
if isLoading {
|
||||||
ForEach(gamesByDate, id: \.date) { dateGroup in
|
HStack {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
ThemedSpinnerCompact(size: 16)
|
||||||
// Date header
|
Text("Loading games...")
|
||||||
Text(dateGroup.date)
|
.font(.caption)
|
||||||
.font(.caption)
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||||
.foregroundStyle(Theme.warmOrange)
|
}
|
||||||
.padding(.horizontal, Theme.Spacing.md)
|
.padding(Theme.Spacing.md)
|
||||||
.padding(.top, Theme.Spacing.sm)
|
} else if games.isEmpty {
|
||||||
.padding(.bottom, Theme.Spacing.xs)
|
Text("No games scheduled")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||||
|
.padding(Theme.Spacing.md)
|
||||||
|
} else {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
ForEach(gamesByDate, id: \.date) { dateGroup in
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
// Date header
|
||||||
|
Text(dateGroup.date)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Theme.warmOrange)
|
||||||
|
.padding(.horizontal, Theme.Spacing.md)
|
||||||
|
.padding(.top, Theme.Spacing.sm)
|
||||||
|
.padding(.bottom, Theme.Spacing.xs)
|
||||||
|
|
||||||
// Games on this date
|
// Games on this date
|
||||||
ForEach(dateGroup.games) { game in
|
ForEach(dateGroup.games) { game in
|
||||||
GameCalendarRow(
|
GameCalendarRow(
|
||||||
game: game,
|
game: game,
|
||||||
isSelected: selectedIds.contains(game.id),
|
isSelected: selectedIds.contains(game.id),
|
||||||
onTap: {
|
onTap: {
|
||||||
// Disable implicit animation to prevent weird morphing effect
|
// Disable implicit animation to prevent weird morphing effect
|
||||||
var transaction = Transaction()
|
var transaction = Transaction()
|
||||||
transaction.disablesAnimations = true
|
transaction.disablesAnimations = true
|
||||||
withTransaction(transaction) {
|
withTransaction(transaction) {
|
||||||
if selectedIds.contains(game.id) {
|
if selectedIds.contains(game.id) {
|
||||||
selectedIds.remove(game.id)
|
selectedIds.remove(game.id)
|
||||||
} else {
|
selectedGamesCache.removeValue(forKey: game.id)
|
||||||
selectedIds.insert(game.id)
|
} else {
|
||||||
|
selectedIds.insert(game.id)
|
||||||
|
selectedGamesCache[game.id] = game
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.padding(.leading, Theme.Spacing.md)
|
||||||
|
.background(Theme.cardBackgroundElevated(colorScheme).opacity(0.5))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadGames() {
|
||||||
|
loadingTeams.insert(team.id)
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let loadedGames = try await dataProvider.gamesForTeam(teamId: team.id)
|
||||||
|
await MainActor.run {
|
||||||
|
gamesCache[team.id] = loadedGames
|
||||||
|
loadingTeams.remove(team.id)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
await MainActor.run {
|
||||||
|
gamesCache[team.id] = []
|
||||||
|
loadingTeams.remove(team.id)
|
||||||
}
|
}
|
||||||
.padding(.leading, Theme.Spacing.md)
|
|
||||||
.background(Theme.cardBackgroundElevated(colorScheme).opacity(0.5))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1362,20 +1367,6 @@ struct GameCalendarRow: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Team With Games Model
|
|
||||||
|
|
||||||
struct TeamWithGames: Identifiable {
|
|
||||||
let team: Team
|
|
||||||
let sport: Sport
|
|
||||||
var games: [RichGame]
|
|
||||||
|
|
||||||
var id: String { team.id }
|
|
||||||
|
|
||||||
var sortedGames: [RichGame] {
|
|
||||||
games.sorted { $0.game.dateTime < $1.game.dateTime }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Location Search Sheet
|
// MARK: - Location Search Sheet
|
||||||
|
|
||||||
struct LocationSearchSheet: View {
|
struct LocationSearchSheet: View {
|
||||||
|
|||||||
Reference in New Issue
Block a user