perf: add pagination to schedule list

Loads 50 games at a time to fix lag with large datasets.

- Add pagination state (displayedGames, currentPage, allFilteredGames)
- Add loadInitialGames() and loadMoreGames() methods
- Update gamesBySport to use displayedGames
- Add infinite scroll trigger via onAppear on last game
- Add ProgressView indicator when more games available
- Reset pagination when search text changes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-01-12 19:07:54 -06:00
parent 3530b31cca
commit 746b56bc77
2 changed files with 66 additions and 3 deletions

View File

@@ -26,6 +26,22 @@ final class ScheduleViewModel {
private let dataProvider = AppDataProvider.shared private let dataProvider = AppDataProvider.shared
// MARK: - Pagination
private let pageSize = 50
private(set) var displayedGames: [RichGame] = []
private var currentPage = 0
private var allFilteredGames: [RichGame] = []
var hasMoreGames: Bool {
displayedGames.count < allFilteredGames.count
}
/// The last game in the displayed list, used for infinite scroll detection
var lastDisplayedGame: RichGame? {
displayedGames.last
}
// MARK: - Computed Properties // MARK: - Computed Properties
var filteredGames: [RichGame] { var filteredGames: [RichGame] {
@@ -51,7 +67,7 @@ final class ScheduleViewModel {
} }
var gamesBySport: [(sport: Sport, games: [RichGame])] { var gamesBySport: [(sport: Sport, games: [RichGame])] {
let grouped = Dictionary(grouping: filteredGames) { game in let grouped = Dictionary(grouping: displayedGames) { game in
game.game.sport game.game.sport
} }
// Sort by sport order (use allCases index for consistent ordering) // Sort by sport order (use allCases index for consistent ordering)
@@ -69,11 +85,35 @@ final class ScheduleViewModel {
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 {
guard !selectedSports.isEmpty else { guard !selectedSports.isEmpty else {
games = [] games = []
updateFilteredGames()
return return
} }
@@ -92,6 +132,7 @@ final class ScheduleViewModel {
self.errorMessage = providerError self.errorMessage = providerError
self.error = dataProvider.error self.error = dataProvider.error
isLoading = false isLoading = false
updateFilteredGames()
return return
} }
@@ -109,6 +150,7 @@ final class ScheduleViewModel {
} }
isLoading = false isLoading = false
updateFilteredGames()
} }
func clearError() { func clearError() {

View File

@@ -12,11 +12,11 @@ struct ScheduleListView: View {
var body: some View { var body: some View {
Group { Group {
if viewModel.isLoading && viewModel.games.isEmpty { if viewModel.isLoading && viewModel.displayedGames.isEmpty {
loadingView loadingView
} else if let errorMessage = viewModel.errorMessage { } else if let errorMessage = viewModel.errorMessage {
errorView(message: errorMessage) errorView(message: errorMessage)
} else if viewModel.games.isEmpty { } else if viewModel.displayedGames.isEmpty {
emptyView emptyView
} else { } else {
gamesList gamesList
@@ -58,6 +58,9 @@ struct ScheduleListView: View {
.task { .task {
await viewModel.loadGames() await viewModel.loadGames()
} }
.onChange(of: viewModel.searchText) {
viewModel.updateFilteredGames()
}
} }
// MARK: - Sport Filter // MARK: - Sport Filter
@@ -93,6 +96,12 @@ 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) {
@@ -103,6 +112,18 @@ 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)