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:
Trey t
2026-01-07 00:46:40 -06:00
commit 9088b46563
84 changed files with 180371 additions and 0 deletions

View File

@@ -0,0 +1,306 @@
//
// HomeView.swift
// SportsTime
//
import SwiftUI
import SwiftData
struct HomeView: View {
@Environment(\.modelContext) private var modelContext
@Query(sort: \SavedTrip.updatedAt, order: .reverse) private var savedTrips: [SavedTrip]
@State private var showNewTrip = false
@State private var selectedTab = 0
var body: some View {
TabView(selection: $selectedTab) {
// Home Tab
NavigationStack {
ScrollView {
VStack(spacing: 24) {
// Hero Card
heroCard
// Quick Actions
quickActions
// Saved Trips
if !savedTrips.isEmpty {
savedTripsSection
}
// Featured / Tips
tipsSection
}
.padding()
}
.navigationTitle("Sport Travel Planner")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
showNewTrip = true
} label: {
Image(systemName: "plus")
}
}
}
}
.tabItem {
Label("Home", systemImage: "house.fill")
}
.tag(0)
// Schedule Tab
NavigationStack {
ScheduleListView()
}
.tabItem {
Label("Schedule", systemImage: "calendar")
}
.tag(1)
// My Trips Tab
NavigationStack {
SavedTripsListView(trips: savedTrips)
}
.tabItem {
Label("My Trips", systemImage: "suitcase.fill")
}
.tag(2)
// Settings Tab
NavigationStack {
SettingsView()
}
.tabItem {
Label("Settings", systemImage: "gear")
}
.tag(3)
}
.sheet(isPresented: $showNewTrip) {
TripCreationView()
}
}
// MARK: - Hero Card
private var heroCard: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Plan Your Ultimate Sports Road Trip")
.font(.title2)
.fontWeight(.bold)
Text("Visit multiple stadiums, catch live games, and create unforgettable memories.")
.font(.subheadline)
.foregroundStyle(.secondary)
Button {
showNewTrip = true
} label: {
Text("Start Planning")
.fontWeight(.semibold)
.frame(maxWidth: .infinity)
.padding()
.background(Color.blue)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
.padding()
.background(
LinearGradient(
colors: [.blue.opacity(0.1), .green.opacity(0.1)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.clipShape(RoundedRectangle(cornerRadius: 16))
}
// MARK: - Quick Actions
private var quickActions: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Quick Start")
.font(.headline)
HStack(spacing: 12) {
ForEach(Sport.supported) { sport in
QuickSportButton(sport: sport) {
// Start trip with this sport pre-selected
showNewTrip = true
}
}
}
}
}
// MARK: - Saved Trips
private var savedTripsSection: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("Recent Trips")
.font(.headline)
Spacer()
Button("See All") {
selectedTab = 2
}
.font(.subheadline)
}
ForEach(savedTrips.prefix(3)) { savedTrip in
if let trip = savedTrip.trip {
SavedTripCard(trip: trip)
}
}
}
}
// MARK: - Tips
private var tipsSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Planning Tips")
.font(.headline)
VStack(spacing: 8) {
TipRow(icon: "calendar.badge.clock", title: "Check schedules early", subtitle: "Game times can change, sync often")
TipRow(icon: "car.fill", title: "Plan rest days", subtitle: "Don't overdo the driving")
TipRow(icon: "star.fill", title: "Mark must-sees", subtitle: "Ensure your favorite matchups are included")
}
.padding()
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
}
// MARK: - Supporting Views
struct QuickSportButton: View {
let sport: Sport
let action: () -> Void
var body: some View {
Button(action: action) {
VStack(spacing: 8) {
Image(systemName: sport.iconName)
.font(.title)
Text(sport.rawValue)
.font(.caption)
.fontWeight(.medium)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.buttonStyle(.plain)
}
}
struct SavedTripCard: View {
let trip: Trip
var body: some View {
NavigationLink {
TripDetailView(trip: trip, games: [:])
} label: {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(trip.name)
.font(.subheadline)
.fontWeight(.semibold)
Text(trip.formattedDateRange)
.font(.caption)
.foregroundStyle(.secondary)
HStack(spacing: 8) {
Label("\(trip.stops.count) cities", systemImage: "mappin")
Label("\(trip.totalGames) games", systemImage: "sportscourt")
}
.font(.caption2)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.foregroundStyle(.secondary)
}
.padding()
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.buttonStyle(.plain)
}
}
struct TipRow: View {
let icon: String
let title: String
let subtitle: String
var body: some View {
HStack(spacing: 12) {
Image(systemName: icon)
.font(.title3)
.foregroundStyle(.blue)
.frame(width: 30)
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.subheadline)
.fontWeight(.medium)
Text(subtitle)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
}
}
}
// MARK: - Saved Trips List View
struct SavedTripsListView: View {
let trips: [SavedTrip]
var body: some View {
List {
if trips.isEmpty {
ContentUnavailableView(
"No Saved Trips",
systemImage: "suitcase",
description: Text("Your planned trips will appear here")
)
} else {
ForEach(trips) { savedTrip in
if let trip = savedTrip.trip {
NavigationLink {
TripDetailView(trip: trip, games: [:])
} label: {
VStack(alignment: .leading) {
Text(trip.name)
.font(.headline)
Text(trip.formattedDateRange)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
}
}
.navigationTitle("My Trips")
}
}
#Preview {
HomeView()
.modelContainer(for: SavedTrip.self, inMemory: true)
}

View File

@@ -0,0 +1,134 @@
//
// 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: - 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 hasFilters: Bool {
selectedSports.count < Sport.supported.count || !searchText.isEmpty
}
// MARK: - Actions
func loadGames() async {
guard !selectedSports.isEmpty else {
games = []
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
return
}
games = try await dataProvider.fetchRichGames(
sports: selectedSports,
startDate: startDate,
endDate: endDate
)
} catch let cloudKitError as CloudKitError {
self.error = cloudKitError
self.errorMessage = cloudKitError.errorDescription
print("CloudKit error loading games: \(cloudKitError.errorDescription ?? "Unknown")")
} catch {
self.error = error
self.errorMessage = error.localizedDescription
print("Failed to load games: \(error)")
}
isLoading = false
}
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()
}
}
}

View 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()
}
}

View File

@@ -0,0 +1,154 @@
//
// SettingsViewModel.swift
// SportsTime
//
import Foundation
import SwiftUI
@MainActor
@Observable
final class SettingsViewModel {
// MARK: - User Preferences (persisted via UserDefaults)
var selectedSports: Set<Sport> {
didSet { savePreferences() }
}
var maxDrivingHoursPerDay: Int {
didSet { savePreferences() }
}
var preferredGameTime: PreferredGameTime {
didSet { savePreferences() }
}
var includePlayoffGames: Bool {
didSet { savePreferences() }
}
var notificationsEnabled: Bool {
didSet { savePreferences() }
}
// MARK: - Sync State
private(set) var isSyncing = false
private(set) var lastSyncDate: Date?
private(set) var syncError: String?
// MARK: - App Info
let appVersion: String
let buildNumber: String
// MARK: - Initialization
init() {
// Load from UserDefaults using local variables first
let defaults = UserDefaults.standard
// Selected sports
if let sportStrings = defaults.stringArray(forKey: "selectedSports") {
self.selectedSports = Set(sportStrings.compactMap { Sport(rawValue: $0) })
} else {
self.selectedSports = Set(Sport.supported)
}
// Travel preferences - use local variable to avoid self access before init complete
let savedDrivingHours = defaults.integer(forKey: "maxDrivingHoursPerDay")
self.maxDrivingHoursPerDay = savedDrivingHours == 0 ? 8 : savedDrivingHours
if let timeRaw = defaults.string(forKey: "preferredGameTime"),
let time = PreferredGameTime(rawValue: timeRaw) {
self.preferredGameTime = time
} else {
self.preferredGameTime = .evening
}
self.includePlayoffGames = defaults.object(forKey: "includePlayoffGames") as? Bool ?? true
self.notificationsEnabled = defaults.object(forKey: "notificationsEnabled") as? Bool ?? true
// Last sync
self.lastSyncDate = defaults.object(forKey: "lastSyncDate") as? Date
// App info
self.appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0"
self.buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1"
}
// MARK: - Actions
func syncSchedules() async {
isSyncing = true
syncError = nil
do {
// Trigger data reload from provider
await AppDataProvider.shared.loadInitialData()
lastSyncDate = Date()
UserDefaults.standard.set(lastSyncDate, forKey: "lastSyncDate")
} catch {
syncError = error.localizedDescription
}
isSyncing = false
}
func toggleSport(_ sport: Sport) {
if selectedSports.contains(sport) {
// Don't allow removing all sports
guard selectedSports.count > 1 else { return }
selectedSports.remove(sport)
} else {
selectedSports.insert(sport)
}
}
func resetToDefaults() {
selectedSports = Set(Sport.supported)
maxDrivingHoursPerDay = 8
preferredGameTime = .evening
includePlayoffGames = true
notificationsEnabled = true
}
// MARK: - Persistence
private func savePreferences() {
let defaults = UserDefaults.standard
defaults.set(selectedSports.map(\.rawValue), forKey: "selectedSports")
defaults.set(maxDrivingHoursPerDay, forKey: "maxDrivingHoursPerDay")
defaults.set(preferredGameTime.rawValue, forKey: "preferredGameTime")
defaults.set(includePlayoffGames, forKey: "includePlayoffGames")
defaults.set(notificationsEnabled, forKey: "notificationsEnabled")
}
}
// MARK: - Supporting Types
enum PreferredGameTime: String, CaseIterable, Identifiable {
case any = "any"
case afternoon = "afternoon"
case evening = "evening"
var id: String { rawValue }
var displayName: String {
switch self {
case .any: return "Any Time"
case .afternoon: return "Afternoon"
case .evening: return "Evening"
}
}
var description: String {
switch self {
case .any: return "No preference"
case .afternoon: return "1 PM - 5 PM"
case .evening: return "6 PM - 10 PM"
}
}
}

View File

@@ -0,0 +1,228 @@
//
// SettingsView.swift
// SportsTime
//
import SwiftUI
struct SettingsView: View {
@State private var viewModel = SettingsViewModel()
@State private var showResetConfirmation = false
var body: some View {
List {
// Sports Preferences
sportsSection
// Travel Preferences
travelSection
// Game Preferences
gamePreferencesSection
// Notifications
notificationsSection
// Data Sync
dataSection
// About
aboutSection
// Reset
resetSection
}
.navigationTitle("Settings")
.alert("Reset Settings", isPresented: $showResetConfirmation) {
Button("Cancel", role: .cancel) { }
Button("Reset", role: .destructive) {
viewModel.resetToDefaults()
}
} message: {
Text("This will reset all settings to their default values.")
}
}
// MARK: - Sports Section
private var sportsSection: some View {
Section {
ForEach(Sport.supported) { sport in
Toggle(isOn: Binding(
get: { viewModel.selectedSports.contains(sport) },
set: { _ in viewModel.toggleSport(sport) }
)) {
Label {
Text(sport.displayName)
} icon: {
Image(systemName: sport.iconName)
.foregroundStyle(sportColor(for: sport))
}
}
}
} header: {
Text("Favorite Sports")
} footer: {
Text("Selected sports will be shown by default in schedules and trip planning.")
}
}
// MARK: - Travel Section
private var travelSection: some View {
Section {
Stepper(value: $viewModel.maxDrivingHoursPerDay, in: 2...12) {
HStack {
Text("Max Driving Per Day")
Spacer()
Text("\(viewModel.maxDrivingHoursPerDay) hours")
.foregroundStyle(.secondary)
}
}
} header: {
Text("Travel Preferences")
} footer: {
Text("Trips will be optimized to keep daily driving within this limit.")
}
}
// MARK: - Game Preferences Section
private var gamePreferencesSection: some View {
Section {
Picker("Preferred Game Time", selection: $viewModel.preferredGameTime) {
ForEach(PreferredGameTime.allCases) { time in
VStack(alignment: .leading) {
Text(time.displayName)
}
.tag(time)
}
}
Toggle("Include Playoff Games", isOn: $viewModel.includePlayoffGames)
} header: {
Text("Game Preferences")
} footer: {
Text("These preferences affect trip optimization.")
}
}
// MARK: - Notifications Section
private var notificationsSection: some View {
Section {
Toggle("Schedule Updates", isOn: $viewModel.notificationsEnabled)
} header: {
Text("Notifications")
} footer: {
Text("Get notified when games in your trips are rescheduled.")
}
}
// MARK: - Data Section
private var dataSection: some View {
Section {
Button {
Task {
await viewModel.syncSchedules()
}
} label: {
HStack {
Label("Sync Schedules", systemImage: "arrow.triangle.2.circlepath")
Spacer()
if viewModel.isSyncing {
ProgressView()
}
}
}
.disabled(viewModel.isSyncing)
if let lastSync = viewModel.lastSyncDate {
HStack {
Text("Last Sync")
Spacer()
Text(lastSync, style: .relative)
.foregroundStyle(.secondary)
}
}
if let error = viewModel.syncError {
HStack {
Image(systemName: "exclamationmark.triangle")
.foregroundStyle(.red)
Text(error)
.font(.caption)
.foregroundStyle(.red)
}
}
} header: {
Text("Data")
} footer: {
#if targetEnvironment(simulator)
Text("Using stub data (Simulator mode)")
#else
Text("Schedule data is synced from CloudKit.")
#endif
}
}
// MARK: - About Section
private var aboutSection: some View {
Section {
HStack {
Text("Version")
Spacer()
Text("\(viewModel.appVersion) (\(viewModel.buildNumber))")
.foregroundStyle(.secondary)
}
Link(destination: URL(string: "https://sportstime.app/privacy")!) {
Label("Privacy Policy", systemImage: "hand.raised")
}
Link(destination: URL(string: "https://sportstime.app/terms")!) {
Label("Terms of Service", systemImage: "doc.text")
}
Link(destination: URL(string: "mailto:support@sportstime.app")!) {
Label("Contact Support", systemImage: "envelope")
}
} header: {
Text("About")
}
}
// MARK: - Reset Section
private var resetSection: some View {
Section {
Button(role: .destructive) {
showResetConfirmation = true
} label: {
Label("Reset to Defaults", systemImage: "arrow.counterclockwise")
}
}
}
// MARK: - Helpers
private func sportColor(for sport: Sport) -> Color {
switch sport {
case .mlb: return .red
case .nba: return .orange
case .nhl: return .blue
case .nfl: return .green
case .mls: return .purple
}
}
}
#Preview {
NavigationStack {
SettingsView()
}
}

View File

@@ -0,0 +1,467 @@
//
// TripCreationViewModel.swift
// SportsTime
//
import Foundation
import SwiftUI
import Observation
import CoreLocation
@Observable
final class TripCreationViewModel {
// MARK: - State
enum ViewState: Equatable {
case editing
case planning
case completed(Trip)
case error(String)
static func == (lhs: ViewState, rhs: ViewState) -> Bool {
switch (lhs, rhs) {
case (.editing, .editing): return true
case (.planning, .planning): return true
case (.completed(let t1), .completed(let t2)): return t1.id == t2.id
case (.error(let e1), .error(let e2)): return e1 == e2
default: return false
}
}
}
var viewState: ViewState = .editing
// MARK: - Planning Mode
var planningMode: PlanningMode = .dateRange
// MARK: - Form Fields
// Locations (used in .locations mode)
var startLocationText: String = ""
var endLocationText: String = ""
var startLocation: LocationInput?
var endLocation: LocationInput?
// Sports
var selectedSports: Set<Sport> = [.mlb]
// Dates
var startDate: Date = Date()
var endDate: Date = Date().addingTimeInterval(86400 * 7)
// Trip duration for game-first mode (days before/after selected games)
var tripBufferDays: Int = 2
// Games
var mustSeeGameIds: Set<UUID> = []
var availableGames: [RichGame] = []
var isLoadingGames: Bool = false
// Travel
var travelMode: TravelMode = .drive
var routePreference: RoutePreference = .balanced
// Constraints
var useStopCount: Bool = true
var numberOfStops: Int = 5
var leisureLevel: LeisureLevel = .moderate
// Optional
var mustStopLocations: [LocationInput] = []
var preferredCities: [String] = []
var needsEVCharging: Bool = false
var lodgingType: LodgingType = .hotel
var numberOfDrivers: Int = 1
var maxDrivingHoursPerDriver: Double = 8
var catchOtherSports: Bool = false
// MARK: - Dependencies
private let planningEngine = TripPlanningEngine()
private let locationService = LocationService.shared
private let dataProvider = AppDataProvider.shared
// MARK: - Cached Data
private var teams: [UUID: Team] = [:]
private var stadiums: [UUID: Stadium] = [:]
private var games: [Game] = []
// MARK: - Computed Properties
var isFormValid: Bool {
switch planningMode {
case .dateRange:
// Need: sports + valid date range
return !selectedSports.isEmpty && endDate > startDate
case .gameFirst:
// Need: at least one selected game + sports
return !mustSeeGameIds.isEmpty && !selectedSports.isEmpty
case .locations:
// Need: start + end locations + sports
return !startLocationText.isEmpty &&
!endLocationText.isEmpty &&
!selectedSports.isEmpty
}
}
var formValidationMessage: String? {
switch planningMode {
case .dateRange:
if selectedSports.isEmpty { return "Select at least one sport" }
if endDate <= startDate { return "End date must be after start date" }
case .gameFirst:
if mustSeeGameIds.isEmpty { return "Select at least one game" }
if selectedSports.isEmpty { return "Select at least one sport" }
case .locations:
if startLocationText.isEmpty { return "Enter a starting location" }
if endLocationText.isEmpty { return "Enter an ending location" }
if selectedSports.isEmpty { return "Select at least one sport" }
}
return nil
}
var tripDurationDays: Int {
let days = Calendar.current.dateComponents([.day], from: startDate, to: endDate).day ?? 0
return max(1, days)
}
var selectedGamesCount: Int {
mustSeeGameIds.count
}
var selectedGames: [RichGame] {
availableGames.filter { mustSeeGameIds.contains($0.game.id) }
}
/// Computed date range for game-first mode based on selected games
var gameFirstDateRange: (start: Date, end: Date)? {
guard !selectedGames.isEmpty else { return nil }
let gameDates = selectedGames.map { $0.game.dateTime }
guard let earliest = gameDates.min(),
let latest = gameDates.max() else { return nil }
let calendar = Calendar.current
let bufferedStart = calendar.date(byAdding: .day, value: -tripBufferDays, to: earliest) ?? earliest
let bufferedEnd = calendar.date(byAdding: .day, value: tripBufferDays, to: latest) ?? latest
return (bufferedStart, bufferedEnd)
}
// MARK: - Actions
func loadScheduleData() async {
do {
// Ensure initial data is loaded
if dataProvider.teams.isEmpty {
await dataProvider.loadInitialData()
}
// Use cached teams and stadiums from data provider
for team in dataProvider.teams {
teams[team.id] = team
}
for stadium in dataProvider.stadiums {
stadiums[stadium.id] = stadium
}
// Fetch games
games = try await dataProvider.fetchGames(
sports: selectedSports,
startDate: startDate,
endDate: endDate
)
// Build rich games for display
availableGames = games.compactMap { game -> RichGame? in
guard let homeTeam = teams[game.homeTeamId],
let awayTeam = teams[game.awayTeamId],
let stadium = stadiums[game.stadiumId] else {
return nil
}
return RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium)
}
} catch {
viewState = .error("Failed to load schedule data: \(error.localizedDescription)")
}
}
func resolveLocations() async {
do {
if !startLocationText.isEmpty {
startLocation = try await locationService.resolveLocation(
LocationInput(name: startLocationText, address: startLocationText)
)
}
if !endLocationText.isEmpty {
endLocation = try await locationService.resolveLocation(
LocationInput(name: endLocationText, address: endLocationText)
)
}
} catch {
viewState = .error("Failed to resolve locations: \(error.localizedDescription)")
}
}
func planTrip() async {
guard isFormValid else { return }
viewState = .planning
do {
// Mode-specific setup
var effectiveStartDate = startDate
var effectiveEndDate = endDate
var resolvedStartLocation: LocationInput?
var resolvedEndLocation: LocationInput?
switch planningMode {
case .dateRange:
// Use provided date range, no location needed
// Games will be found within the date range across all regions
effectiveStartDate = startDate
effectiveEndDate = endDate
case .gameFirst:
// Calculate date range from selected games + buffer
if let dateRange = gameFirstDateRange {
effectiveStartDate = dateRange.start
effectiveEndDate = dateRange.end
}
// Derive start/end locations from first/last game stadiums
if let firstGame = selectedGames.sorted(by: { $0.game.dateTime < $1.game.dateTime }).first,
let lastGame = selectedGames.sorted(by: { $0.game.dateTime < $1.game.dateTime }).last {
resolvedStartLocation = LocationInput(
name: firstGame.stadium.city,
coordinate: firstGame.stadium.coordinate,
address: "\(firstGame.stadium.city), \(firstGame.stadium.state)"
)
resolvedEndLocation = LocationInput(
name: lastGame.stadium.city,
coordinate: lastGame.stadium.coordinate,
address: "\(lastGame.stadium.city), \(lastGame.stadium.state)"
)
}
case .locations:
// Resolve provided locations
await resolveLocations()
resolvedStartLocation = startLocation
resolvedEndLocation = endLocation
guard resolvedStartLocation != nil, resolvedEndLocation != nil else {
viewState = .error("Could not resolve start or end location")
return
}
}
// Ensure we have games data
if games.isEmpty {
await loadScheduleData()
}
// Build preferences
let preferences = TripPreferences(
planningMode: planningMode,
startLocation: resolvedStartLocation,
endLocation: resolvedEndLocation,
sports: selectedSports,
mustSeeGameIds: mustSeeGameIds,
travelMode: travelMode,
startDate: effectiveStartDate,
endDate: effectiveEndDate,
numberOfStops: useStopCount ? numberOfStops : nil,
tripDuration: useStopCount ? nil : tripDurationDays,
leisureLevel: leisureLevel,
mustStopLocations: mustStopLocations,
preferredCities: preferredCities,
routePreference: routePreference,
needsEVCharging: needsEVCharging,
lodgingType: lodgingType,
numberOfDrivers: numberOfDrivers,
maxDrivingHoursPerDriver: maxDrivingHoursPerDriver,
catchOtherSports: catchOtherSports
)
// Build planning request
let request = PlanningRequest(
preferences: preferences,
availableGames: games,
teams: teams,
stadiums: stadiums
)
// Plan the trip
let result = planningEngine.planItineraries(request: request)
switch result {
case .success(let options):
guard let bestOption = options.first else {
viewState = .error("No valid itinerary found")
return
}
// Convert ItineraryOption to Trip
let trip = convertToTrip(option: bestOption, preferences: preferences)
viewState = .completed(trip)
case .failure(let failure):
viewState = .error(failureMessage(for: failure))
}
} catch {
viewState = .error("Trip planning failed: \(error.localizedDescription)")
}
}
func toggleMustSeeGame(_ gameId: UUID) {
if mustSeeGameIds.contains(gameId) {
mustSeeGameIds.remove(gameId)
} else {
mustSeeGameIds.insert(gameId)
}
}
func switchPlanningMode(_ mode: PlanningMode) {
planningMode = mode
// Clear mode-specific selections when switching
switch mode {
case .dateRange:
startLocationText = ""
endLocationText = ""
startLocation = nil
endLocation = nil
case .gameFirst:
// Keep games, clear locations
startLocationText = ""
endLocationText = ""
startLocation = nil
endLocation = nil
case .locations:
// Keep locations, optionally keep selected games
break
}
}
/// Load games for browsing in game-first mode
func loadGamesForBrowsing() async {
isLoadingGames = true
do {
// Ensure initial data is loaded
if dataProvider.teams.isEmpty {
await dataProvider.loadInitialData()
}
// Use cached teams and stadiums from data provider
for team in dataProvider.teams {
teams[team.id] = team
}
for stadium in dataProvider.stadiums {
stadiums[stadium.id] = stadium
}
// Fetch games for next 90 days for browsing
let browseEndDate = Calendar.current.date(byAdding: .day, value: 90, to: Date()) ?? endDate
games = try await dataProvider.fetchGames(
sports: selectedSports,
startDate: Date(),
endDate: browseEndDate
)
// Build rich games for display
availableGames = games.compactMap { game -> RichGame? in
guard let homeTeam = teams[game.homeTeamId],
let awayTeam = teams[game.awayTeamId],
let stadium = stadiums[game.stadiumId] else {
return nil
}
return RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium)
}.sorted { $0.game.dateTime < $1.game.dateTime }
} catch {
viewState = .error("Failed to load games: \(error.localizedDescription)")
}
isLoadingGames = false
}
func addMustStopLocation(_ location: LocationInput) {
guard !mustStopLocations.contains(where: { $0.name == location.name }) else { return }
mustStopLocations.append(location)
}
func removeMustStopLocation(_ location: LocationInput) {
mustStopLocations.removeAll { $0.name == location.name }
}
func addPreferredCity(_ city: String) {
guard !city.isEmpty, !preferredCities.contains(city) else { return }
preferredCities.append(city)
}
func removePreferredCity(_ city: String) {
preferredCities.removeAll { $0 == city }
}
func reset() {
viewState = .editing
planningMode = .dateRange
startLocationText = ""
endLocationText = ""
startLocation = nil
endLocation = nil
selectedSports = [.mlb]
startDate = Date()
endDate = Date().addingTimeInterval(86400 * 7)
tripBufferDays = 2
mustSeeGameIds = []
numberOfStops = 5
leisureLevel = .moderate
mustStopLocations = []
preferredCities = []
availableGames = []
isLoadingGames = false
}
// MARK: - Conversion Helpers
private func convertToTrip(option: ItineraryOption, preferences: TripPreferences) -> Trip {
// Convert ItineraryStops to TripStops
let tripStops = option.stops.enumerated().map { index, stop in
TripStop(
stopNumber: index + 1,
city: stop.city,
state: stop.state,
coordinate: stop.coordinate,
arrivalDate: stop.arrivalDate,
departureDate: stop.departureDate,
games: stop.games,
isRestDay: stop.games.isEmpty
)
}
return Trip(
name: generateTripName(from: tripStops),
preferences: preferences,
stops: tripStops,
travelSegments: option.travelSegments,
totalGames: option.totalGames,
totalDistanceMeters: option.totalDistanceMiles * 1609.34,
totalDrivingSeconds: option.totalDrivingHours * 3600
)
}
private func generateTripName(from stops: [TripStop]) -> String {
let cities = stops.compactMap { $0.city }.prefix(3)
if cities.count <= 1 {
return cities.first ?? "Road Trip"
}
return cities.joined(separator: "")
}
private func failureMessage(for failure: PlanningFailure) -> String {
failure.message
}
}

View File

@@ -0,0 +1,831 @@
//
// TripCreationView.swift
// SportsTime
//
import SwiftUI
struct TripCreationView: View {
@State private var viewModel = TripCreationViewModel()
@State private var showGamePicker = false
@State private var showCityInput = false
@State private var cityInputType: CityInputType = .mustStop
@State private var showLocationBanner = true
@State private var showTripDetail = false
@State private var completedTrip: Trip?
enum CityInputType {
case mustStop
case preferred
}
var body: some View {
NavigationStack {
Form {
// Planning Mode Selector
planningModeSection
// Location Permission Banner (only for locations mode)
if viewModel.planningMode == .locations && showLocationBanner {
Section {
LocationPermissionBanner(isPresented: $showLocationBanner)
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
}
}
// Mode-specific sections
switch viewModel.planningMode {
case .dateRange:
// Sports + Dates
sportsSection
datesSection
case .gameFirst:
// Sports + Game Picker
sportsSection
gameBrowserSection
tripBufferSection
case .locations:
// Locations + Sports + optional games
locationSection
sportsSection
datesSection
gamesSection
}
// Common sections
travelSection
constraintsSection
optionalSection
// Validation message
if let message = viewModel.formValidationMessage {
Section {
Label(message, systemImage: "exclamationmark.triangle")
.foregroundStyle(.orange)
}
}
}
.navigationTitle("Plan Your Trip")
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Plan") {
Task {
await viewModel.planTrip()
}
}
.disabled(!viewModel.isFormValid)
}
}
.overlay {
if case .planning = viewModel.viewState {
planningOverlay
}
}
.sheet(isPresented: $showGamePicker) {
GamePickerSheet(
games: viewModel.availableGames,
selectedIds: $viewModel.mustSeeGameIds
)
}
.sheet(isPresented: $showCityInput) {
LocationSearchSheet(inputType: cityInputType) { location in
switch cityInputType {
case .mustStop:
viewModel.addMustStopLocation(location)
case .preferred:
viewModel.addPreferredCity(location.name)
}
}
}
.alert("Error", isPresented: Binding(
get: { viewModel.viewState.isError },
set: { if !$0 { viewModel.viewState = .editing } }
)) {
Button("OK") {
viewModel.viewState = .editing
}
} message: {
if case .error(let message) = viewModel.viewState {
Text(message)
}
}
.navigationDestination(isPresented: $showTripDetail) {
if let trip = completedTrip {
TripDetailView(trip: trip, games: buildGamesDictionary())
}
}
.onChange(of: viewModel.viewState) { _, newState in
if case .completed(let trip) = newState {
completedTrip = trip
showTripDetail = true
}
}
.onChange(of: showTripDetail) { _, isShowing in
if !isShowing {
// User navigated back, reset to editing state
viewModel.viewState = .editing
completedTrip = nil
}
}
.task {
await viewModel.loadScheduleData()
}
}
}
// MARK: - Sections
private var planningModeSection: some View {
Section {
Picker("Planning Mode", selection: $viewModel.planningMode) {
ForEach(PlanningMode.allCases) { mode in
Label(mode.displayName, systemImage: mode.iconName)
.tag(mode)
}
}
.pickerStyle(.segmented)
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
Text(viewModel.planningMode.description)
.font(.caption)
.foregroundStyle(.secondary)
}
}
private var locationSection: some View {
Section("Locations") {
TextField("Start Location", text: $viewModel.startLocationText)
.textContentType(.addressCity)
TextField("End Location", text: $viewModel.endLocationText)
.textContentType(.addressCity)
}
}
private var gameBrowserSection: some View {
Section("Select Games") {
if viewModel.isLoadingGames {
HStack {
ProgressView()
Text("Loading games...")
.foregroundStyle(.secondary)
}
} else if viewModel.availableGames.isEmpty {
HStack {
ProgressView()
Text("Loading games...")
.foregroundStyle(.secondary)
}
.task {
await viewModel.loadGamesForBrowsing()
}
} else {
Button {
showGamePicker = true
} label: {
HStack {
Image(systemName: "sportscourt")
.foregroundStyle(.blue)
VStack(alignment: .leading, spacing: 2) {
Text("Browse Teams & Games")
.foregroundStyle(.primary)
Text("\(viewModel.availableGames.count) games available")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.foregroundStyle(.secondary)
}
}
.buttonStyle(.plain)
}
// Show selected games summary
if !viewModel.mustSeeGameIds.isEmpty {
VStack(alignment: .leading, spacing: 8) {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
Text("\(viewModel.mustSeeGameIds.count) game(s) selected")
.fontWeight(.medium)
}
// Show selected games preview
ForEach(viewModel.selectedGames.prefix(3)) { game in
HStack(spacing: 8) {
Image(systemName: game.game.sport.iconName)
.font(.caption)
.foregroundStyle(.secondary)
Text("\(game.awayTeam.abbreviation) @ \(game.homeTeam.abbreviation)")
.font(.caption)
Spacer()
Text(game.game.formattedDate)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
if viewModel.selectedGames.count > 3 {
Text("+ \(viewModel.selectedGames.count - 3) more")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
}
private var tripBufferSection: some View {
Section("Trip Duration") {
Stepper("Buffer Days: \(viewModel.tripBufferDays)", value: $viewModel.tripBufferDays, in: 0...7)
if let dateRange = viewModel.gameFirstDateRange {
HStack {
Text("Trip window:")
Spacer()
Text("\(dateRange.start.formatted(date: .abbreviated, time: .omitted)) - \(dateRange.end.formatted(date: .abbreviated, time: .omitted))")
.foregroundStyle(.secondary)
}
}
Text("Days before first game and after last game for travel/rest")
.font(.caption)
.foregroundStyle(.secondary)
}
}
private var sportsSection: some View {
Section("Sports") {
ForEach(Sport.supported) { sport in
Toggle(isOn: binding(for: sport)) {
Label(sport.rawValue, systemImage: sport.iconName)
}
}
}
}
private var datesSection: some View {
Section("Dates") {
DatePicker("Start Date", selection: $viewModel.startDate, displayedComponents: .date)
DatePicker("End Date", selection: $viewModel.endDate, displayedComponents: .date)
Text("\(viewModel.tripDurationDays) day trip")
.foregroundStyle(.secondary)
}
}
private var gamesSection: some View {
Section("Must-See Games") {
Button {
showGamePicker = true
} label: {
HStack {
Text("Select Games")
Spacer()
Text("\(viewModel.selectedGamesCount) selected")
.foregroundStyle(.secondary)
}
}
}
}
private var travelSection: some View {
Section("Travel") {
Picker("Travel Mode", selection: $viewModel.travelMode) {
ForEach(TravelMode.allCases) { mode in
Label(mode.displayName, systemImage: mode.iconName)
.tag(mode)
}
}
Picker("Route Preference", selection: $viewModel.routePreference) {
ForEach(RoutePreference.allCases) { pref in
Text(pref.displayName).tag(pref)
}
}
}
}
private var constraintsSection: some View {
Section("Trip Style") {
Toggle("Use Stop Count", isOn: $viewModel.useStopCount)
if viewModel.useStopCount {
Stepper("Number of Stops: \(viewModel.numberOfStops)", value: $viewModel.numberOfStops, in: 1...20)
}
Picker("Pace", selection: $viewModel.leisureLevel) {
ForEach(LeisureLevel.allCases) { level in
VStack(alignment: .leading) {
Text(level.displayName)
}
.tag(level)
}
}
Text(viewModel.leisureLevel.description)
.font(.caption)
.foregroundStyle(.secondary)
}
}
private var optionalSection: some View {
Section("Optional") {
// Must-Stop Locations
DisclosureGroup("Must-Stop Locations (\(viewModel.mustStopLocations.count))") {
ForEach(viewModel.mustStopLocations, id: \.name) { location in
HStack {
VStack(alignment: .leading) {
Text(location.name)
if let address = location.address, !address.isEmpty {
Text(address)
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
Button(role: .destructive) {
viewModel.removeMustStopLocation(location)
} label: {
Image(systemName: "minus.circle.fill")
}
}
}
Button("Add Location") {
cityInputType = .mustStop
showCityInput = true
}
}
// EV Charging
if viewModel.travelMode == .drive {
Toggle("EV Charging Needed", isOn: $viewModel.needsEVCharging)
}
// Lodging
Picker("Lodging Type", selection: $viewModel.lodgingType) {
ForEach(LodgingType.allCases) { type in
Label(type.displayName, systemImage: type.iconName)
.tag(type)
}
}
// Drivers
if viewModel.travelMode == .drive {
Stepper("Drivers: \(viewModel.numberOfDrivers)", value: $viewModel.numberOfDrivers, in: 1...4)
HStack {
Text("Max Hours/Driver/Day")
Spacer()
Text("\(Int(viewModel.maxDrivingHoursPerDriver))h")
}
Slider(value: $viewModel.maxDrivingHoursPerDriver, in: 4...12, step: 1)
}
// Other Sports
Toggle("Find Other Sports Along Route", isOn: $viewModel.catchOtherSports)
}
}
private var planningOverlay: some View {
ZStack {
Color.black.opacity(0.4)
.ignoresSafeArea()
VStack(spacing: 20) {
ProgressView()
.scaleEffect(1.5)
Text("Planning your trip...")
.font(.headline)
.foregroundStyle(.white)
Text("Finding the best route and games")
.font(.subheadline)
.foregroundStyle(.white.opacity(0.8))
}
.padding(40)
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 20))
}
}
// MARK: - Helpers
private func binding(for sport: Sport) -> Binding<Bool> {
Binding(
get: { viewModel.selectedSports.contains(sport) },
set: { isSelected in
if isSelected {
viewModel.selectedSports.insert(sport)
} else {
viewModel.selectedSports.remove(sport)
}
}
)
}
private func buildGamesDictionary() -> [UUID: RichGame] {
Dictionary(uniqueKeysWithValues: viewModel.availableGames.map { ($0.id, $0) })
}
}
// MARK: - View State Extensions
extension TripCreationViewModel.ViewState {
var isError: Bool {
if case .error = self { return true }
return false
}
var isCompleted: Bool {
if case .completed = self { return true }
return false
}
}
// MARK: - Game Picker Sheet (Team-based selection)
struct GamePickerSheet: View {
let games: [RichGame]
@Binding var selectedIds: Set<UUID>
@Environment(\.dismiss) private var dismiss
// Group games by team (both home and away)
private var teamsList: [TeamWithGames] {
var teamsDict: [UUID: TeamWithGames] = [:]
for game in games {
// Add to home team
if var teamData = teamsDict[game.homeTeam.id] {
teamData.games.append(game)
teamsDict[game.homeTeam.id] = teamData
} else {
teamsDict[game.homeTeam.id] = TeamWithGames(
team: game.homeTeam,
sport: game.game.sport,
games: [game]
)
}
// Add to away team
if var teamData = teamsDict[game.awayTeam.id] {
teamData.games.append(game)
teamsDict[game.awayTeam.id] = teamData
} else {
teamsDict[game.awayTeam.id] = TeamWithGames(
team: game.awayTeam,
sport: game.game.sport,
games: [game]
)
}
}
return teamsDict.values
.sorted { $0.team.name < $1.team.name }
}
private var teamsBySport: [(sport: Sport, teams: [TeamWithGames])] {
let grouped = Dictionary(grouping: teamsList) { $0.sport }
return Sport.supported
.filter { grouped[$0] != nil }
.map { sport in
(sport, grouped[sport]!.sorted { $0.team.name < $1.team.name })
}
}
private var selectedGamesCount: Int {
selectedIds.count
}
var body: some View {
NavigationStack {
List {
// Selected games summary
if !selectedIds.isEmpty {
Section {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
Text("\(selectedGamesCount) game(s) selected")
.fontWeight(.medium)
Spacer()
}
}
}
// Teams by sport
ForEach(teamsBySport, id: \.sport.id) { sportGroup in
Section(sportGroup.sport.rawValue) {
ForEach(sportGroup.teams) { teamData in
NavigationLink {
TeamGamesView(
teamData: teamData,
selectedIds: $selectedIds
)
} label: {
TeamRow(teamData: teamData, selectedIds: selectedIds)
}
}
}
}
}
.navigationTitle("Select Teams")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
if !selectedIds.isEmpty {
Button("Reset") {
selectedIds.removeAll()
}
.foregroundStyle(.red)
}
}
ToolbarItem(placement: .confirmationAction) {
Button("Done") {
dismiss()
}
}
}
}
}
}
// MARK: - Team With Games Model
struct TeamWithGames: Identifiable {
let team: Team
let sport: Sport
var games: [RichGame]
var id: UUID { team.id }
var sortedGames: [RichGame] {
games.sorted { $0.game.dateTime < $1.game.dateTime }
}
}
// MARK: - Team Row
struct TeamRow: View {
let teamData: TeamWithGames
let selectedIds: Set<UUID>
private var selectedCount: Int {
teamData.games.filter { selectedIds.contains($0.id) }.count
}
var body: some View {
HStack(spacing: 12) {
// Team color indicator
if let colorHex = teamData.team.primaryColor {
Circle()
.fill(Color(hex: colorHex) ?? .gray)
.frame(width: 12, height: 12)
}
VStack(alignment: .leading, spacing: 2) {
Text("\(teamData.team.city) \(teamData.team.name)")
.font(.subheadline)
.fontWeight(.medium)
Text("\(teamData.games.count) game(s) available")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if selectedCount > 0 {
Text("\(selectedCount)")
.font(.caption)
.fontWeight(.bold)
.foregroundStyle(.white)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(.blue)
.clipShape(Capsule())
}
}
}
}
// MARK: - Team Games View
struct TeamGamesView: View {
let teamData: TeamWithGames
@Binding var selectedIds: Set<UUID>
var body: some View {
List {
ForEach(teamData.sortedGames) { game in
GameRow(game: game, isSelected: selectedIds.contains(game.id)) {
if selectedIds.contains(game.id) {
selectedIds.remove(game.id)
} else {
selectedIds.insert(game.id)
}
}
}
}
.navigationTitle("\(teamData.team.city) \(teamData.team.name)")
.navigationBarTitleDisplayMode(.inline)
}
}
struct GameRow: View {
let game: RichGame
let isSelected: Bool
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(game.matchupDescription)
.font(.headline)
Text(game.venueDescription)
.font(.caption)
.foregroundStyle(.secondary)
Text("\(game.game.formattedDate)\(game.game.gameTime)")
.font(.caption2)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
.foregroundStyle(isSelected ? .blue : .gray)
.font(.title2)
}
}
.buttonStyle(.plain)
}
}
struct GameSelectRow: View {
let game: RichGame
let isSelected: Bool
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
HStack(spacing: 12) {
// Sport icon
Image(systemName: game.game.sport.iconName)
.font(.title3)
.foregroundStyle(isSelected ? .blue : .secondary)
.frame(width: 24)
VStack(alignment: .leading, spacing: 2) {
Text("\(game.awayTeam.abbreviation) @ \(game.homeTeam.abbreviation)")
.font(.subheadline)
.fontWeight(.medium)
Text("\(game.game.formattedDate)\(game.game.gameTime)")
.font(.caption)
.foregroundStyle(.secondary)
Text(game.stadium.city)
.font(.caption2)
.foregroundStyle(.tertiary)
}
Spacer()
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
.foregroundStyle(isSelected ? .blue : .gray.opacity(0.5))
.font(.title3)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
// MARK: - Location Search Sheet
struct LocationSearchSheet: View {
let inputType: TripCreationView.CityInputType
let onAdd: (LocationInput) -> Void
@Environment(\.dismiss) private var dismiss
@State private var searchText = ""
@State private var searchResults: [LocationSearchResult] = []
@State private var isSearching = false
@State private var searchTask: Task<Void, Never>?
private let locationService = LocationService.shared
var body: some View {
NavigationStack {
VStack(spacing: 0) {
// Search field
HStack {
Image(systemName: "magnifyingglass")
.foregroundStyle(.secondary)
TextField("Search cities, addresses, places...", text: $searchText)
.textFieldStyle(.plain)
.autocorrectionDisabled()
if isSearching {
ProgressView()
.scaleEffect(0.8)
} else if !searchText.isEmpty {
Button {
searchText = ""
searchResults = []
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.secondary)
}
}
}
.padding()
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 10))
.padding()
// Results list
if searchResults.isEmpty && !searchText.isEmpty && !isSearching {
ContentUnavailableView(
"No Results",
systemImage: "mappin.slash",
description: Text("Try a different search term")
)
} else {
List(searchResults) { result in
Button {
onAdd(result.toLocationInput())
dismiss()
} label: {
HStack {
Image(systemName: "mappin.circle.fill")
.foregroundStyle(.red)
.font(.title2)
VStack(alignment: .leading) {
Text(result.name)
.foregroundStyle(.primary)
if !result.address.isEmpty {
Text(result.address)
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
Image(systemName: "plus.circle")
.foregroundStyle(.blue)
}
}
.buttonStyle(.plain)
}
.listStyle(.plain)
}
Spacer()
}
.navigationTitle(inputType == .mustStop ? "Add Must-Stop" : "Add Location")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
}
}
.presentationDetents([.large])
.onChange(of: searchText) { _, newValue in
// Debounce search
searchTask?.cancel()
searchTask = Task {
try? await Task.sleep(for: .milliseconds(300))
guard !Task.isCancelled else { return }
await performSearch(query: newValue)
}
}
}
private func performSearch(query: String) async {
guard !query.isEmpty else {
searchResults = []
return
}
isSearching = true
do {
searchResults = try await locationService.searchLocations(query)
} catch {
searchResults = []
}
isSearching = false
}
}
#Preview {
TripCreationView()
}

View File

@@ -0,0 +1,883 @@
//
// TripDetailView.swift
// SportsTime
//
import SwiftUI
import SwiftData
import MapKit
struct TripDetailView: View {
@Environment(\.modelContext) private var modelContext
let trip: Trip
let games: [UUID: RichGame]
@State private var selectedDay: ItineraryDay?
@State private var showExportSheet = false
@State private var showShareSheet = false
@State private var exportURL: URL?
@State private var shareURL: URL?
@State private var mapCameraPosition: MapCameraPosition = .automatic
@State private var isSaved = false
@State private var showSaveConfirmation = false
@State private var routePolylines: [MKPolyline] = []
@State private var isLoadingRoutes = false
private let exportService = ExportService()
private let dataProvider = AppDataProvider.shared
var body: some View {
ScrollView {
VStack(spacing: 20) {
// Header
tripHeader
// Score Card
if let score = trip.score {
scoreCard(score)
}
// Stats
statsGrid
// Map Preview
mapPreview
// Day-by-Day Itinerary
itinerarySection
}
.padding()
}
.navigationTitle(trip.name)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItemGroup(placement: .primaryAction) {
Button {
Task {
await shareTrip()
}
} label: {
Image(systemName: "square.and.arrow.up")
}
Menu {
Button {
Task {
await exportPDF()
}
} label: {
Label("Export PDF", systemImage: "doc.fill")
}
Button {
saveTrip()
} label: {
Label(isSaved ? "Saved" : "Save Trip", systemImage: isSaved ? "bookmark.fill" : "bookmark")
}
.disabled(isSaved)
} label: {
Image(systemName: "ellipsis.circle")
}
}
}
.sheet(isPresented: $showExportSheet) {
if let url = exportURL {
ShareSheet(items: [url])
}
}
.sheet(isPresented: $showShareSheet) {
if let url = shareURL {
ShareSheet(items: [url])
} else {
ShareSheet(items: [trip.name, trip.formattedDateRange])
}
}
.alert("Trip Saved", isPresented: $showSaveConfirmation) {
Button("OK", role: .cancel) { }
} message: {
Text("Your trip has been saved and can be accessed from My Trips.")
}
.onAppear {
checkIfSaved()
}
}
// MARK: - Header
private var tripHeader: some View {
VStack(alignment: .leading, spacing: 8) {
Text(trip.formattedDateRange)
.font(.subheadline)
.foregroundStyle(.secondary)
HStack(spacing: 16) {
ForEach(Array(trip.uniqueSports), id: \.self) { sport in
Label(sport.rawValue, systemImage: sport.iconName)
.font(.caption)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(Color.blue.opacity(0.1))
.clipShape(Capsule())
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
// MARK: - Score Card
private func scoreCard(_ score: TripScore) -> some View {
VStack(spacing: 12) {
HStack {
Text("Trip Score")
.font(.headline)
Spacer()
Text(score.scoreGrade)
.font(.largeTitle)
.fontWeight(.bold)
.foregroundStyle(.green)
}
HStack(spacing: 20) {
scoreItem(label: "Games", value: score.gameQualityScore)
scoreItem(label: "Route", value: score.routeEfficiencyScore)
scoreItem(label: "Balance", value: score.leisureBalanceScore)
scoreItem(label: "Prefs", value: score.preferenceAlignmentScore)
}
}
.padding()
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
private func scoreItem(label: String, value: Double) -> some View {
VStack(spacing: 4) {
Text(String(format: "%.0f", value))
.font(.headline)
Text(label)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
// MARK: - Stats Grid
private var statsGrid: some View {
LazyVGrid(columns: [
GridItem(.flexible()),
GridItem(.flexible()),
GridItem(.flexible())
], spacing: 16) {
statCell(value: "\(trip.tripDuration)", label: "Days", icon: "calendar")
statCell(value: "\(trip.stops.count)", label: "Cities", icon: "mappin.circle")
statCell(value: "\(trip.totalGames)", label: "Games", icon: "sportscourt")
statCell(value: trip.formattedTotalDistance, label: "Distance", icon: "road.lanes")
statCell(value: trip.formattedTotalDriving, label: "Driving", icon: "car")
statCell(value: String(format: "%.1fh", trip.averageDrivingHoursPerDay), label: "Avg/Day", icon: "gauge.medium")
}
}
private func statCell(value: String, label: String, icon: String) -> some View {
VStack(spacing: 6) {
Image(systemName: icon)
.font(.title2)
.foregroundStyle(.blue)
Text(value)
.font(.headline)
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 10))
}
// MARK: - Map Preview
private var mapPreview: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Route")
.font(.headline)
Spacer()
if isLoadingRoutes {
ProgressView()
.scaleEffect(0.7)
}
}
Map(position: $mapCameraPosition) {
// Add markers for each stop
ForEach(stopCoordinates.indices, id: \.self) { index in
let stop = stopCoordinates[index]
Marker(stop.name, coordinate: stop.coordinate)
.tint(.blue)
}
// Add actual driving route polylines
ForEach(routePolylines.indices, id: \.self) { index in
MapPolyline(routePolylines[index])
.stroke(.blue, lineWidth: 3)
}
}
.frame(height: 200)
.clipShape(RoundedRectangle(cornerRadius: 12))
.task {
updateMapRegion()
await fetchDrivingRoutes()
}
}
}
/// Fetch actual driving routes using MKDirections
private func fetchDrivingRoutes() async {
let stops = stopCoordinates
guard stops.count >= 2 else { return }
isLoadingRoutes = true
var polylines: [MKPolyline] = []
for i in 0..<(stops.count - 1) {
let source = stops[i]
let destination = stops[i + 1]
let request = MKDirections.Request()
request.source = MKMapItem(placemark: MKPlacemark(coordinate: source.coordinate))
request.destination = MKMapItem(placemark: MKPlacemark(coordinate: destination.coordinate))
request.transportType = .automobile
let directions = MKDirections(request: request)
do {
let response = try await directions.calculate()
if let route = response.routes.first {
polylines.append(route.polyline)
}
} catch {
// Fallback to straight line if directions fail
print("Failed to get directions from \(source.name) to \(destination.name): \(error)")
let straightLine = MKPolyline(coordinates: [source.coordinate, destination.coordinate], count: 2)
polylines.append(straightLine)
}
}
routePolylines = polylines
isLoadingRoutes = false
}
/// Get coordinates for all stops (from stop coordinate or stadium)
private var stopCoordinates: [(name: String, coordinate: CLLocationCoordinate2D)] {
trip.stops.compactMap { stop -> (String, CLLocationCoordinate2D)? in
// First try to use the stop's stored coordinate
if let coord = stop.coordinate {
return (stop.city, coord)
}
// Fall back to stadium coordinate if available
if let stadiumId = stop.stadium,
let stadium = dataProvider.stadium(for: stadiumId) {
return (stadium.name, stadium.coordinate)
}
return nil
}
}
/// Resolved stadiums from trip stops (for markers)
private var tripStadiums: [Stadium] {
trip.stops.compactMap { stop in
guard let stadiumId = stop.stadium else { return nil }
return dataProvider.stadium(for: stadiumId)
}
}
private func updateMapRegion() {
guard !stopCoordinates.isEmpty else { return }
let coordinates = stopCoordinates.map(\.coordinate)
let lats = coordinates.map(\.latitude)
let lons = coordinates.map(\.longitude)
guard let minLat = lats.min(),
let maxLat = lats.max(),
let minLon = lons.min(),
let maxLon = lons.max() else { return }
let center = CLLocationCoordinate2D(
latitude: (minLat + maxLat) / 2,
longitude: (minLon + maxLon) / 2
)
// Add padding to the span
let latSpan = (maxLat - minLat) * 1.3 + 0.5
let lonSpan = (maxLon - minLon) * 1.3 + 0.5
mapCameraPosition = .region(MKCoordinateRegion(
center: center,
span: MKCoordinateSpan(latitudeDelta: max(latSpan, 1), longitudeDelta: max(lonSpan, 1))
))
}
// MARK: - Itinerary
private var itinerarySection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Route Options")
.font(.headline)
let combinations = computeRouteCombinations()
if combinations.count == 1 {
// Single route - show fully expanded
SingleRouteView(
route: combinations[0],
days: trip.itineraryDays(),
games: games
)
} else {
// Multiple combinations - show each as expandable row
ForEach(Array(combinations.enumerated()), id: \.offset) { index, route in
RouteCombinationRow(
routeNumber: index + 1,
route: route,
days: trip.itineraryDays(),
games: games,
totalRoutes: combinations.count
)
}
}
}
}
/// Computes all possible route combinations across days
private func computeRouteCombinations() -> [[DayChoice]] {
let days = trip.itineraryDays()
let calendar = Calendar.current
// Build options for each day
var dayOptions: [[DayChoice]] = []
for day in days {
let dayStart = calendar.startOfDay(for: day.date)
// Find stops with games on this day
let stopsWithGames = day.stops.filter { stop in
stop.games.compactMap { games[$0] }.contains { richGame in
calendar.startOfDay(for: richGame.game.dateTime) == dayStart
}
}
if stopsWithGames.isEmpty {
// Rest day or travel day - use first stop or create empty
if let firstStop = day.stops.first {
dayOptions.append([DayChoice(dayNumber: day.dayNumber, stop: firstStop, game: nil)])
}
} else {
// Create choices for each stop with games
let choices = stopsWithGames.compactMap { stop -> DayChoice? in
let gamesAtStop = stop.games.compactMap { games[$0] }.filter { richGame in
calendar.startOfDay(for: richGame.game.dateTime) == dayStart
}
return DayChoice(dayNumber: day.dayNumber, stop: stop, game: gamesAtStop.first)
}
if !choices.isEmpty {
dayOptions.append(choices)
}
}
}
// Compute cartesian product of all day options
return cartesianProduct(dayOptions)
}
/// Computes cartesian product of arrays
private func cartesianProduct(_ arrays: [[DayChoice]]) -> [[DayChoice]] {
guard !arrays.isEmpty else { return [[]] }
var result: [[DayChoice]] = [[]]
for array in arrays {
var newResult: [[DayChoice]] = []
for existing in result {
for element in array {
newResult.append(existing + [element])
}
}
result = newResult
}
return result
}
/// Detects if there are games in different cities on the same day
private func detectConflicts(for day: ItineraryDay) -> DayConflictInfo {
let calendar = Calendar.current
let dayStart = calendar.startOfDay(for: day.date)
// Find all stops that have games on this specific day
let stopsWithGamesToday = day.stops.filter { stop in
stop.games.compactMap { games[$0] }.contains { richGame in
calendar.startOfDay(for: richGame.game.dateTime) == dayStart
}
}
// Get unique cities with games today
let citiesWithGames = Set(stopsWithGamesToday.map { $0.city })
if citiesWithGames.count > 1 {
return DayConflictInfo(
hasConflict: true,
conflictingStops: stopsWithGamesToday,
conflictingCities: Array(citiesWithGames)
)
}
return DayConflictInfo(hasConflict: false, conflictingStops: [], conflictingCities: [])
}
// MARK: - Actions
private func exportPDF() async {
do {
let url = try await exportService.exportToPDF(trip: trip, games: games)
exportURL = url
showExportSheet = true
} catch {
print("Failed to export PDF: \(error)")
}
}
private func shareTrip() async {
shareURL = await exportService.shareTrip(trip)
showShareSheet = true
}
private func saveTrip() {
guard let savedTrip = SavedTrip.from(trip, status: .planned) else {
print("Failed to create SavedTrip")
return
}
modelContext.insert(savedTrip)
do {
try modelContext.save()
isSaved = true
showSaveConfirmation = true
} catch {
print("Failed to save trip: \(error)")
}
}
private func checkIfSaved() {
let tripId = trip.id
let descriptor = FetchDescriptor<SavedTrip>(
predicate: #Predicate { $0.id == tripId }
)
if let count = try? modelContext.fetchCount(descriptor), count > 0 {
isSaved = true
}
}
}
// MARK: - Day Conflict Info
struct DayConflictInfo {
let hasConflict: Bool
let conflictingStops: [TripStop]
let conflictingCities: [String]
var warningMessage: String {
guard hasConflict else { return "" }
let otherCities = conflictingCities.joined(separator: ", ")
return "Scheduling conflict: Games in \(otherCities) on the same day"
}
}
// MARK: - Day Choice (Route Option)
/// Represents a choice for a single day in a route
struct DayChoice: Hashable {
let dayNumber: Int
let stop: TripStop
let game: RichGame?
func hash(into hasher: inout Hasher) {
hasher.combine(dayNumber)
hasher.combine(stop.city)
}
static func == (lhs: DayChoice, rhs: DayChoice) -> Bool {
lhs.dayNumber == rhs.dayNumber && lhs.stop.city == rhs.stop.city
}
}
// MARK: - Route Combination Row (Expandable full route)
struct RouteCombinationRow: View {
let routeNumber: Int
let route: [DayChoice]
let days: [ItineraryDay]
let games: [UUID: RichGame]
let totalRoutes: Int
@State private var isExpanded = false
/// Summary string like "CLE @ SD CHC @ ATH ATL @ LAD"
private var routeSummary: String {
route.compactMap { choice -> String? in
guard let game = choice.game else { return nil }
return game.matchupDescription
}.joined(separator: "")
}
/// Cities in the route
private var routeCities: String {
route.map { $0.stop.city }.joined(separator: "")
}
var body: some View {
VStack(spacing: 0) {
// Header (always visible, tappable)
Button {
withAnimation(.easeInOut(duration: 0.25)) {
isExpanded.toggle()
}
} label: {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 6) {
// Route number badge
Text("Route \(routeNumber)")
.font(.caption)
.fontWeight(.bold)
.foregroundStyle(.white)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.blue)
.clipShape(Capsule())
// Game sequence summary
Text(routeSummary)
.font(.subheadline)
.fontWeight(.medium)
.foregroundStyle(.primary)
.lineLimit(2)
.multilineTextAlignment(.leading)
// Cities
Text(routeCities)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
.font(.caption)
.fontWeight(.semibold)
.foregroundStyle(.secondary)
.padding(8)
.background(Color(.tertiarySystemFill))
.clipShape(Circle())
}
.padding()
.background(Color(.secondarySystemBackground))
}
.buttonStyle(.plain)
// Expanded content - full day-by-day itinerary
if isExpanded {
VStack(spacing: 8) {
ForEach(route, id: \.dayNumber) { choice in
if let day = days.first(where: { $0.dayNumber == choice.dayNumber }) {
RouteDayCard(day: day, choice: choice, games: games)
}
}
}
.padding(12)
.background(Color(.secondarySystemBackground))
}
}
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(Color.blue.opacity(0.2), lineWidth: 1)
)
}
}
// MARK: - Single Route View (Auto-expanded when only one option)
struct SingleRouteView: View {
let route: [DayChoice]
let days: [ItineraryDay]
let games: [UUID: RichGame]
var body: some View {
VStack(spacing: 12) {
ForEach(route, id: \.dayNumber) { choice in
if let day = days.first(where: { $0.dayNumber == choice.dayNumber }) {
RouteDayCard(day: day, choice: choice, games: games)
}
}
}
}
}
// MARK: - Route Day Card (Individual day within a route)
struct RouteDayCard: View {
let day: ItineraryDay
let choice: DayChoice
let games: [UUID: RichGame]
private var gamesOnThisDay: [RichGame] {
let calendar = Calendar.current
let dayStart = calendar.startOfDay(for: day.date)
return choice.stop.games.compactMap { games[$0] }.filter { richGame in
calendar.startOfDay(for: richGame.game.dateTime) == dayStart
}
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
// Day header
HStack {
Text("Day \(day.dayNumber)")
.font(.subheadline)
.fontWeight(.semibold)
.foregroundStyle(.blue)
Text(day.formattedDate)
.font(.subheadline)
.foregroundStyle(.secondary)
Spacer()
if gamesOnThisDay.isEmpty {
Text("Rest Day")
.font(.caption)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(Color.green.opacity(0.2))
.clipShape(Capsule())
}
}
// City
Label(choice.stop.city, systemImage: "mappin")
.font(.caption)
.foregroundStyle(.secondary)
// Travel
if day.hasTravelSegment {
ForEach(day.travelSegments) { segment in
HStack(spacing: 4) {
Image(systemName: segment.travelMode.iconName)
Text("\(segment.formattedDistance)\(segment.formattedDuration)")
}
.font(.caption)
.foregroundStyle(.orange)
}
}
// Games
ForEach(gamesOnThisDay, id: \.game.id) { richGame in
HStack {
Image(systemName: richGame.game.sport.iconName)
.foregroundStyle(.blue)
Text(richGame.matchupDescription)
.font(.subheadline)
Spacer()
Text(richGame.game.gameTime)
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.vertical, 4)
}
}
.padding()
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 10))
}
}
// MARK: - Day Card
struct DayCard: View {
let day: ItineraryDay
let games: [UUID: RichGame]
var specificStop: TripStop? = nil
var conflictInfo: DayConflictInfo? = nil
/// The city to display for this card
var primaryCityForDay: String? {
// If a specific stop is provided (conflict mode), use that stop's city
if let stop = specificStop {
return stop.city
}
let calendar = Calendar.current
let dayStart = calendar.startOfDay(for: day.date)
// Find the stop with a game on this day
let primaryStop = day.stops.first { stop in
stop.games.compactMap { games[$0] }.contains { richGame in
calendar.startOfDay(for: richGame.game.dateTime) == dayStart
}
} ?? day.stops.first
return primaryStop?.city
}
/// Games to display on this card
var gamesOnThisDay: [RichGame] {
let calendar = Calendar.current
let dayStart = calendar.startOfDay(for: day.date)
// If a specific stop is provided (conflict mode), only show that stop's games
if let stop = specificStop {
return stop.games.compactMap { games[$0] }.filter { richGame in
calendar.startOfDay(for: richGame.game.dateTime) == dayStart
}
}
// Find the stop where we're actually located on this day
let primaryStop = day.stops.first { stop in
stop.games.compactMap { games[$0] }.contains { richGame in
calendar.startOfDay(for: richGame.game.dateTime) == dayStart
}
} ?? day.stops.first
guard let stop = primaryStop else { return [] }
return stop.games.compactMap { games[$0] }.filter { richGame in
calendar.startOfDay(for: richGame.game.dateTime) == dayStart
}
}
/// Whether this card has a scheduling conflict
var hasConflict: Bool {
conflictInfo?.hasConflict ?? false
}
/// Other cities with conflicting games (excluding current city)
var otherConflictingCities: [String] {
guard let info = conflictInfo, let currentCity = primaryCityForDay else { return [] }
return info.conflictingCities.filter { $0 != currentCity }
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
// Conflict warning banner
if hasConflict {
HStack(spacing: 6) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
Text("Conflict: Also scheduled in \(otherConflictingCities.joined(separator: ", "))")
.font(.caption)
.fontWeight(.medium)
}
.padding(.horizontal, 10)
.padding(.vertical, 6)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.orange.opacity(0.15))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
// Day header
HStack {
Text("Day \(day.dayNumber)")
.font(.subheadline)
.fontWeight(.semibold)
.foregroundStyle(.blue)
Text(day.formattedDate)
.font(.subheadline)
.foregroundStyle(.secondary)
Spacer()
if day.isRestDay && !hasConflict {
Text("Rest Day")
.font(.caption)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(Color.green.opacity(0.2))
.clipShape(Capsule())
}
}
// City
if let city = primaryCityForDay {
Label(city, systemImage: "mappin")
.font(.caption)
.foregroundStyle(.secondary)
}
// Travel (only show if not in conflict mode, to avoid duplication)
if day.hasTravelSegment && specificStop == nil {
ForEach(day.travelSegments) { segment in
HStack(spacing: 4) {
Image(systemName: segment.travelMode.iconName)
Text("\(segment.formattedDistance)\(segment.formattedDuration)")
}
.font(.caption)
.foregroundStyle(.orange)
}
}
// Games
ForEach(gamesOnThisDay, id: \.game.id) { richGame in
HStack {
Image(systemName: richGame.game.sport.iconName)
.foregroundStyle(.blue)
Text(richGame.matchupDescription)
.font(.subheadline)
Spacer()
Text(richGame.game.gameTime)
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.vertical, 4)
}
}
.padding()
.background(hasConflict ? Color.orange.opacity(0.05) : Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(hasConflict ? Color.orange.opacity(0.3) : Color.clear, lineWidth: 1)
)
}
}
// MARK: - Share Sheet
struct ShareSheet: UIViewControllerRepresentable {
let items: [Any]
func makeUIViewController(context: Context) -> UIActivityViewController {
UIActivityViewController(activityItems: items, applicationActivities: nil)
}
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
}
#Preview {
NavigationStack {
TripDetailView(
trip: Trip(
name: "MLB Road Trip",
preferences: TripPreferences(
startLocation: LocationInput(name: "New York"),
endLocation: LocationInput(name: "Chicago")
)
),
games: [:]
)
}
}