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>
342 lines
9.9 KiB
Swift
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()
|
|
}
|
|
}
|