Files
Sportstime/SportsTime/Features/Schedule/Views/ScheduleListView.swift
Trey t 5ed4e309bd feat(schedule): group games by sport instead of date
Games in schedule view now display in sport sections (MLB, NBA, etc.)
with games sorted by date within each section. Each game row shows its
date since the section header now shows sport instead.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 17:36:22 -06:00

342 lines
9.9 KiB
Swift

//
// ScheduleListView.swift
// SportsTime
//
import SwiftUI
struct ScheduleListView: View {
@Environment(\.colorScheme) private var colorScheme
@State private var viewModel = ScheduleViewModel()
@State private var showDatePicker = false
var body: some View {
Group {
if viewModel.isLoading && viewModel.games.isEmpty {
loadingView
} else if let errorMessage = viewModel.errorMessage {
errorView(message: errorMessage)
} else if viewModel.games.isEmpty {
emptyView
} else {
gamesList
}
}
.searchable(text: $viewModel.searchText, prompt: "Search teams or venues")
.scrollContentBackground(.hidden)
.themedBackground()
.toolbar {
ToolbarItem(placement: .primaryAction) {
Menu {
Button {
showDatePicker = true
} label: {
Label("Date Range", systemImage: "calendar")
}
if viewModel.hasFilters {
Button(role: .destructive) {
viewModel.resetFilters()
} label: {
Label("Clear Filters", systemImage: "xmark.circle")
}
}
} label: {
Image(systemName: viewModel.hasFilters ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle")
}
}
}
.sheet(isPresented: $showDatePicker) {
DateRangePickerSheet(
startDate: viewModel.startDate,
endDate: viewModel.endDate
) { start, end in
viewModel.updateDateRange(start: start, end: end)
}
.presentationDetents([.medium])
}
.task {
await viewModel.loadGames()
}
}
// MARK: - Sport Filter
private var sportFilter: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(Sport.supported) { sport in
SportFilterChip(
sport: sport,
isSelected: viewModel.selectedSports.contains(sport)
) {
viewModel.toggleSport(sport)
}
}
}
.padding(.horizontal)
.padding(.vertical, 8)
}
}
// MARK: - Games List
private var gamesList: some View {
List {
Section {
sportFilter
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
}
ForEach(viewModel.gamesBySport, id: \.sport) { sportGroup in
Section {
ForEach(sportGroup.games) { richGame in
GameRowView(game: richGame, showDate: true)
}
} header: {
HStack(spacing: 8) {
Image(systemName: sportGroup.sport.iconName)
Text(sportGroup.sport.rawValue)
}
.font(.headline)
}
.listRowBackground(Theme.cardBackground(colorScheme))
}
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.refreshable {
await viewModel.loadGames()
}
}
// MARK: - Empty State
private var emptyView: some View {
VStack(spacing: 16) {
sportFilter
ContentUnavailableView {
Label("No Games Found", systemImage: "sportscourt")
} description: {
Text("Try adjusting your filters or date range")
} actions: {
Button("Reset Filters") {
viewModel.resetFilters()
}
.buttonStyle(.bordered)
}
}
}
// MARK: - Loading State
private var loadingView: some View {
VStack(spacing: 16) {
ThemedSpinner(size: 44)
Text("Loading schedule...")
.foregroundStyle(.secondary)
}
}
// MARK: - Error State
private func errorView(message: String) -> some View {
ContentUnavailableView {
Label("Unable to Load", systemImage: "exclamationmark.icloud")
} description: {
Text(message)
} actions: {
Button {
viewModel.clearError()
Task {
await viewModel.loadGames()
}
} label: {
Text("Try Again")
}
.buttonStyle(.bordered)
}
}
// MARK: - Helpers
private func formatSectionDate(_ date: Date) -> String {
let calendar = Calendar.current
let formatter = DateFormatter()
if calendar.isDateInToday(date) {
return "Today"
} else if calendar.isDateInTomorrow(date) {
return "Tomorrow"
} else {
formatter.dateFormat = "EEEE, MMM d"
return formatter.string(from: date)
}
}
}
// MARK: - Sport Filter Chip
struct SportFilterChip: View {
let sport: Sport
let isSelected: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(spacing: 6) {
Image(systemName: sport.iconName)
.font(.caption)
Text(sport.rawValue)
.font(.caption)
.fontWeight(.medium)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(isSelected ? Color.blue : Color(.secondarySystemBackground))
.foregroundStyle(isSelected ? .white : .primary)
.clipShape(Capsule())
}
.buttonStyle(.plain)
}
}
// MARK: - Game Row View
struct GameRowView: View {
let game: RichGame
var showDate: Bool = false
var body: some View {
VStack(alignment: .leading, spacing: 8) {
// Date (when grouped by sport)
if showDate {
Text(formattedDate)
.font(.caption)
.fontWeight(.medium)
.foregroundStyle(.secondary)
}
// Teams
HStack {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) {
TeamBadge(team: game.awayTeam, isHome: false)
Text("@")
.font(.caption)
.foregroundStyle(.secondary)
TeamBadge(team: game.homeTeam, isHome: true)
}
}
Spacer()
}
// Game info
HStack(spacing: 12) {
Label(game.game.gameTime, systemImage: "clock")
Label(game.stadium.name, systemImage: "building.2")
if let broadcast = game.game.broadcastInfo {
Label(broadcast, systemImage: "tv")
}
}
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.vertical, 4)
}
private var formattedDate: String {
let formatter = DateFormatter()
formatter.dateFormat = "EEE, MMM d"
return formatter.string(from: game.game.dateTime)
}
}
// MARK: - Team Badge
struct TeamBadge: View {
let team: Team
let isHome: Bool
var body: some View {
HStack(spacing: 4) {
if let colorHex = team.primaryColor {
Circle()
.fill(Color(hex: colorHex))
.frame(width: 8, height: 8)
}
Text(team.abbreviation)
.font(.subheadline)
.fontWeight(isHome ? .bold : .regular)
Text(team.city)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
// MARK: - Date Range Picker Sheet
struct DateRangePickerSheet: View {
@Environment(\.dismiss) private var dismiss
@State var startDate: Date
@State var endDate: Date
let onApply: (Date, Date) -> Void
var body: some View {
NavigationStack {
Form {
Section("Date Range") {
DatePicker("Start", selection: $startDate, displayedComponents: .date)
DatePicker("End", selection: $endDate, in: startDate..., displayedComponents: .date)
}
Section {
Button("Next 7 Days") {
startDate = Date()
endDate = Calendar.current.date(byAdding: .day, value: 7, to: Date()) ?? Date()
}
Button("Next 14 Days") {
startDate = Date()
endDate = Calendar.current.date(byAdding: .day, value: 14, to: Date()) ?? Date()
}
Button("Next 30 Days") {
startDate = Date()
endDate = Calendar.current.date(byAdding: .day, value: 30, to: Date()) ?? Date()
}
}
}
.navigationTitle("Select Dates")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .confirmationAction) {
Button("Apply") {
onApply(startDate, endDate)
dismiss()
}
}
}
}
}
}
#Preview {
NavigationStack {
ScheduleListView()
}
}