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>
190 lines
5.4 KiB
Swift
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()
|
|
}
|
|
}
|
|
}
|