Files
Sportstime/SportsTime/Features/Schedule/ViewModels/ScheduleViewModel.swift
Trey t 746b56bc77 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>
2026-01-12 19:07:54 -06:00

190 lines
5.4 KiB
Swift

//
// ScheduleViewModel.swift
// SportsTime
//
import Foundation
import SwiftUI
@MainActor
@Observable
final class ScheduleViewModel {
// MARK: - Filter State
var selectedSports: Set<Sport> = Set(Sport.supported)
var startDate: Date = Date()
var endDate: Date = Calendar.current.date(byAdding: .day, value: 14, to: Date()) ?? Date()
var searchText: String = ""
// MARK: - Data State
private(set) var games: [RichGame] = []
private(set) var isLoading = false
private(set) var error: Error?
private(set) var errorMessage: String?
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
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 {
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
func loadGames() async {
guard !selectedSports.isEmpty else {
games = []
updateFilteredGames()
return
}
isLoading = true
error = nil
errorMessage = nil
do {
// Load initial data if needed
if dataProvider.teams.isEmpty {
await dataProvider.loadInitialData()
}
// Check if data provider had an error
if let providerError = dataProvider.errorMessage {
self.errorMessage = providerError
self.error = dataProvider.error
isLoading = false
updateFilteredGames()
return
}
games = try await dataProvider.filterRichGames(
sports: selectedSports,
startDate: startDate,
endDate: endDate
)
} catch let cloudKitError as CloudKitError {
self.error = cloudKitError
self.errorMessage = cloudKitError.errorDescription
} catch {
self.error = error
self.errorMessage = error.localizedDescription
}
isLoading = false
updateFilteredGames()
}
func clearError() {
error = nil
errorMessage = nil
}
func toggleSport(_ sport: Sport) {
if selectedSports.contains(sport) {
selectedSports.remove(sport)
} else {
selectedSports.insert(sport)
}
Task {
await loadGames()
}
}
func resetFilters() {
selectedSports = Set(Sport.supported)
searchText = ""
startDate = Date()
endDate = Calendar.current.date(byAdding: .day, value: 14, to: Date()) ?? Date()
Task {
await loadGames()
}
}
func updateDateRange(start: Date, end: Date) {
startDate = start
endDate = end
Task {
await loadGames()
}
}
}