Initial commit: SportsTime trip planning app
- Three-scenario planning engine (A: date range, B: selected games, C: directional routes) - GeographicRouteExplorer with anchor game support for route exploration - Shared ItineraryBuilder for travel segment calculation - TravelEstimator for driving time/distance estimation - SwiftUI views for trip creation and detail display - CloudKit integration for schedule data - Python scraping scripts for sports schedules 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
343
SportsTime/Features/Schedule/Views/ScheduleListView.swift
Normal file
343
SportsTime/Features/Schedule/Views/ScheduleListView.swift
Normal file
@@ -0,0 +1,343 @@
|
||||
//
|
||||
// ScheduleListView.swift
|
||||
// SportsTime
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ScheduleListView: View {
|
||||
@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
|
||||
}
|
||||
}
|
||||
.navigationTitle("Schedule")
|
||||
.searchable(text: $viewModel.searchText, prompt: "Search teams or venues")
|
||||
.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.gamesByDate, id: \.date) { dateGroup in
|
||||
Section {
|
||||
ForEach(dateGroup.games) { richGame in
|
||||
GameRowView(game: richGame)
|
||||
}
|
||||
} header: {
|
||||
Text(formatSectionDate(dateGroup.date))
|
||||
.font(.headline)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.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) {
|
||||
ProgressView()
|
||||
.scaleEffect(1.5)
|
||||
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 body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
// 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()
|
||||
|
||||
// Sport badge
|
||||
Image(systemName: game.game.sport.iconName)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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) ?? .gray)
|
||||
.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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Color Extension
|
||||
|
||||
extension Color {
|
||||
init?(hex: String) {
|
||||
var hexSanitized = hex.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
hexSanitized = hexSanitized.replacingOccurrences(of: "#", with: "")
|
||||
|
||||
var rgb: UInt64 = 0
|
||||
guard Scanner(string: hexSanitized).scanHexInt64(&rgb) else { return nil }
|
||||
|
||||
let r = Double((rgb & 0xFF0000) >> 16) / 255.0
|
||||
let g = Double((rgb & 0x00FF00) >> 8) / 255.0
|
||||
let b = Double(rgb & 0x0000FF) / 255.0
|
||||
|
||||
self.init(red: r, green: g, blue: b)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
ScheduleListView()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user