diff --git a/SportsTime/Features/Trip/ViewModels/TripWizardViewModel.swift b/SportsTime/Features/Trip/ViewModels/TripWizardViewModel.swift index 9571e9d..721504b 100644 --- a/SportsTime/Features/Trip/ViewModels/TripWizardViewModel.swift +++ b/SportsTime/Features/Trip/ViewModels/TripWizardViewModel.swift @@ -49,6 +49,22 @@ final class TripWizardViewModel { var mustStopLocations: [LocationInput] = [] + // MARK: - Mode-Specific: gameFirst (cascading selection) + + var gamePickerSports: Set = [] + var gamePickerTeamIds: Set = [] + var selectedGameIds: Set = [] + + // MARK: - Mode-Specific: followTeam + + var selectedTeamId: String? = nil + var teamPickerSport: Sport? = nil + + // MARK: - Mode-Specific: locations + + var startLocation: LocationInput? = nil + var endLocation: LocationInput? = nil + // MARK: - Planning State var isPlanning: Bool = false @@ -65,26 +81,65 @@ final class TripWizardViewModel { planningMode != nil } + /// Mode-specific step visibility + var showDatesStep: Bool { + planningMode == .dateRange || planningMode == .followTeam || planningMode == .locations + } + + var showSportsStep: Bool { + planningMode == .dateRange || planningMode == .locations + } + + var showRegionsStep: Bool { + planningMode == .dateRange + } + + var showGamePickerStep: Bool { + planningMode == .gameFirst + } + + var showTeamPickerStep: Bool { + planningMode == .followTeam + } + + var showLocationsStep: Bool { + planningMode == .locations + } + // MARK: - Validation /// All required fields must be set before planning var canPlanTrip: Bool { - planningMode != nil && - hasSetDates && - !selectedSports.isEmpty && - !selectedRegions.isEmpty && - hasSetRoutePreference && - hasSetRepeatCities + guard let mode = planningMode else { return false } + + // Common requirements for all modes + guard hasSetRoutePreference && hasSetRepeatCities else { return false } + + switch mode { + case .dateRange: + return hasSetDates && !selectedSports.isEmpty && !selectedRegions.isEmpty + case .gameFirst: + return !selectedGameIds.isEmpty + case .locations: + return startLocation != nil && endLocation != nil && hasSetDates && !selectedSports.isEmpty + case .followTeam: + return selectedTeamId != nil && hasSetDates + } } /// Field validation for the review step - shows which fields are missing var fieldValidation: FieldValidation { FieldValidation( + planningMode: planningMode, sports: selectedSports.isEmpty ? .missing : .valid, dates: hasSetDates ? .valid : .missing, regions: selectedRegions.isEmpty ? .missing : .valid, routePreference: hasSetRoutePreference ? .valid : .missing, - repeatCities: hasSetRepeatCities ? .valid : .missing + repeatCities: hasSetRepeatCities ? .valid : .missing, + selectedGames: selectedGameIds.isEmpty ? .missing : .valid, + selectedTeam: selectedTeamId == nil ? .missing : .valid, + startLocation: startLocation == nil ? .missing : .valid, + endLocation: endLocation == nil ? .missing : .valid ) } @@ -123,12 +178,26 @@ final class TripWizardViewModel { // MARK: - Reset Logic private func resetAllSelections() { + // Common fields selectedSports = [] hasSetDates = false selectedRegions = [] hasSetRoutePreference = false hasSetRepeatCities = false mustStopLocations = [] + + // gameFirst mode fields + gamePickerSports = [] + gamePickerTeamIds = [] + selectedGameIds = [] + + // followTeam mode fields + selectedTeamId = nil + teamPickerSport = nil + + // locations mode fields + startLocation = nil + endLocation = nil } } @@ -140,9 +209,70 @@ struct FieldValidation { case missing } + let planningMode: PlanningMode? + + // Common fields let sports: Status let dates: Status let regions: Status let routePreference: Status let repeatCities: Status + + // Mode-specific fields + let selectedGames: Status + let selectedTeam: Status + let startLocation: Status + let endLocation: Status + + /// Returns only the fields that are required for the current planning mode + var requiredFields: [(name: String, status: Status)] { + var fields: [(String, Status)] = [] + + guard let mode = planningMode else { return fields } + + switch mode { + case .dateRange: + fields = [ + ("Dates", dates), + ("Sports", sports), + ("Regions", regions), + ("Route Preference", routePreference), + ("Repeat Cities", repeatCities) + ] + case .gameFirst: + fields = [ + ("Games", selectedGames), + ("Route Preference", routePreference), + ("Repeat Cities", repeatCities) + ] + case .locations: + fields = [ + ("Start Location", startLocation), + ("End Location", endLocation), + ("Dates", dates), + ("Sports", sports), + ("Route Preference", routePreference), + ("Repeat Cities", repeatCities) + ] + case .followTeam: + fields = [ + ("Team", selectedTeam), + ("Dates", dates), + ("Route Preference", routePreference), + ("Repeat Cities", repeatCities) + ] + } + + return fields + } + + /// Returns only the missing required fields for the current mode + var missingFields: [String] { + requiredFields.filter { $0.status == .missing }.map { $0.name } + } + + /// Whether all required fields for the current mode are valid + var allRequiredFieldsValid: Bool { + requiredFields.allSatisfy { $0.status == .valid } + } } diff --git a/SportsTime/Features/Trip/Views/Wizard/Steps/GamePickerStep.swift b/SportsTime/Features/Trip/Views/Wizard/Steps/GamePickerStep.swift new file mode 100644 index 0000000..4366137 --- /dev/null +++ b/SportsTime/Features/Trip/Views/Wizard/Steps/GamePickerStep.swift @@ -0,0 +1,524 @@ +// +// GamePickerStep.swift +// SportsTime +// +// Game selection step for "By Games" planning mode. +// Uses sheet-based drill-down: Sports → Teams → Games. +// + +import SwiftUI + +struct GamePickerStep: View { + @Environment(\.colorScheme) private var colorScheme + + @Binding var selectedSports: Set + @Binding var selectedTeamIds: Set + @Binding var selectedGameIds: Set + + @State private var showSportsPicker = false + @State private var showTeamsPicker = false + @State private var showGamesPicker = false + + var body: some View { + VStack(alignment: .leading, spacing: Theme.Spacing.md) { + StepHeader( + title: "Select games for your trip", + subtitle: "Pick sports, then teams, then games" + ) + + // Step 1: Sports Selection + selectionRow( + icon: "sportscourt.fill", + label: "Sports", + value: selectedSports.isEmpty ? nil : selectedSports.map(\.rawValue).sorted().joined(separator: ", "), + placeholder: "Select sports", + onTap: { showSportsPicker = true }, + onClear: { + selectedSports = [] + selectedTeamIds = [] + selectedGameIds = [] + } + ) + + // Step 2: Teams Selection (enabled after sports) + selectionRow( + icon: "person.2.fill", + label: "Teams", + value: selectedTeamIds.isEmpty ? nil : "\(selectedTeamIds.count) team\(selectedTeamIds.count == 1 ? "" : "s")", + placeholder: "Select teams", + isEnabled: !selectedSports.isEmpty, + onTap: { showTeamsPicker = true }, + onClear: { + selectedTeamIds = [] + selectedGameIds = [] + } + ) + + // Step 3: Games Selection (enabled after teams) + selectionRow( + icon: "ticket.fill", + label: "Games", + value: selectedGameIds.isEmpty ? nil : "\(selectedGameIds.count) game\(selectedGameIds.count == 1 ? "" : "s")", + placeholder: "Select games", + isEnabled: !selectedTeamIds.isEmpty, + onTap: { showGamesPicker = true }, + onClear: { selectedGameIds = [] } + ) + + // Selected Games Summary + if !selectedGameIds.isEmpty { + selectedGamesSummary + } + } + .padding(Theme.Spacing.lg) + .background(Theme.cardBackground(colorScheme)) + .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large)) + .overlay { + RoundedRectangle(cornerRadius: Theme.CornerRadius.large) + .stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1) + } + .sheet(isPresented: $showSportsPicker) { + SportsPickerSheet(selectedSports: $selectedSports) { + // Clear downstream when sports change + selectedTeamIds = [] + selectedGameIds = [] + } + } + .sheet(isPresented: $showTeamsPicker) { + TeamsPickerSheet( + selectedSports: selectedSports, + selectedTeamIds: $selectedTeamIds + ) { + // Clear games when teams change + selectedGameIds = [] + } + } + .sheet(isPresented: $showGamesPicker) { + GamesPickerSheet( + selectedTeamIds: selectedTeamIds, + selectedGameIds: $selectedGameIds + ) + } + } + + // MARK: - Selection Row + + private func selectionRow( + icon: String, + label: String, + value: String?, + placeholder: String, + isEnabled: Bool = true, + onTap: @escaping () -> Void, + onClear: @escaping () -> Void + ) -> some View { + VStack(alignment: .leading, spacing: Theme.Spacing.xs) { + Text(label) + .font(.caption) + .fontWeight(.medium) + .foregroundStyle(Theme.textSecondary(colorScheme)) + + Button { + if isEnabled { onTap() } + } label: { + HStack { + Image(systemName: icon) + .foregroundStyle(isEnabled ? Theme.warmOrange : Theme.textMuted(colorScheme)) + + if let value = value { + Text(value) + .font(.subheadline) + .foregroundStyle(Theme.textPrimary(colorScheme)) + .lineLimit(1) + + Spacer() + + Button(action: onClear) { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(Theme.textMuted(colorScheme)) + } + } else { + Text(placeholder) + .font(.subheadline) + .foregroundStyle(Theme.textMuted(colorScheme)) + + Spacer() + + Image(systemName: "chevron.right") + .font(.caption) + .foregroundStyle(Theme.textMuted(colorScheme)) + } + } + .padding(Theme.Spacing.md) + .background(Theme.cardBackgroundElevated(colorScheme)) + .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) + .overlay( + RoundedRectangle(cornerRadius: Theme.CornerRadius.medium) + .stroke(value != nil ? Theme.warmOrange : Theme.textMuted(colorScheme).opacity(0.3), lineWidth: value != nil ? 2 : 1) + ) + .opacity(isEnabled ? 1 : 0.5) + } + .buttonStyle(.plain) + .disabled(!isEnabled) + } + } + + // MARK: - Selected Games Summary + + @State private var summaryGames: [RichGame] = [] + + private var selectedGamesSummary: some View { + VStack(alignment: .leading, spacing: Theme.Spacing.sm) { + HStack { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(Theme.warmOrange) + Text("\(selectedGameIds.count) game\(selectedGameIds.count == 1 ? "" : "s") selected") + .font(.subheadline) + .fontWeight(.medium) + .foregroundStyle(Theme.textPrimary(colorScheme)) + } + + ForEach(summaryGames.filter { selectedGameIds.contains($0.id) }) { game in + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(game.matchupDescription) + .font(.caption) + .fontWeight(.medium) + .foregroundStyle(Theme.textPrimary(colorScheme)) + Text("\(game.stadium.city) • \(game.game.dateTime, style: .date)") + .font(.caption2) + .foregroundStyle(Theme.textMuted(colorScheme)) + } + Spacer() + Button { + selectedGameIds.remove(game.id) + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(Theme.textMuted(colorScheme)) + } + } + .padding(Theme.Spacing.sm) + .background(Theme.cardBackgroundElevated(colorScheme)) + .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.small)) + } + } + .padding(Theme.Spacing.sm) + .background(Theme.warmOrange.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) + .task(id: selectedGameIds) { + await loadSummaryGames() + } + } + + private func loadSummaryGames() async { + var games: [RichGame] = [] + for teamId in selectedTeamIds { + if let teamGames = try? await AppDataProvider.shared.gamesForTeam(teamId: teamId) { + games.append(contentsOf: teamGames) + } + } + await MainActor.run { + summaryGames = Array(Set(games)) + } + } +} + +// MARK: - Sports Picker Sheet + +private struct SportsPickerSheet: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.colorScheme) private var colorScheme + + @Binding var selectedSports: Set + let onChanged: () -> Void + + var body: some View { + NavigationStack { + List { + ForEach(Sport.supported, id: \.self) { sport in + Button { + if selectedSports.contains(sport) { + selectedSports.remove(sport) + } else { + selectedSports.insert(sport) + } + onChanged() + } label: { + HStack(spacing: Theme.Spacing.sm) { + Image(systemName: sport.iconName) + .font(.title2) + .foregroundStyle(sport.themeColor) + .frame(width: 32) + + Text(sport.rawValue) + .font(.body) + .foregroundStyle(Theme.textPrimary(colorScheme)) + + Spacer() + + if selectedSports.contains(sport) { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(Theme.warmOrange) + } else { + Image(systemName: "circle") + .foregroundStyle(Theme.textMuted(colorScheme)) + } + } + .padding(.vertical, Theme.Spacing.xs) + } + .buttonStyle(.plain) + } + } + .listStyle(.plain) + .navigationTitle("Select Sports") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + .fontWeight(.semibold) + } + } + } + .presentationDetents([.medium]) + } +} + +// MARK: - Teams Picker Sheet + +private struct TeamsPickerSheet: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.colorScheme) private var colorScheme + + let selectedSports: Set + @Binding var selectedTeamIds: Set + let onChanged: () -> Void + + @State private var searchText = "" + + private var teams: [Team] { + let allTeams = AppDataProvider.shared.teams + .filter { selectedSports.contains($0.sport) } + .sorted { $0.fullName < $1.fullName } + + if searchText.isEmpty { + return allTeams + } + + return allTeams.filter { + $0.fullName.localizedCaseInsensitiveContains(searchText) || + $0.city.localizedCaseInsensitiveContains(searchText) + } + } + + private var groupedTeams: [(Sport, [Team])] { + let grouped = Dictionary(grouping: teams) { $0.sport } + return selectedSports.sorted { $0.rawValue < $1.rawValue } + .compactMap { sport in + guard let sportTeams = grouped[sport], !sportTeams.isEmpty else { return nil } + return (sport, sportTeams) + } + } + + var body: some View { + NavigationStack { + List { + ForEach(groupedTeams, id: \.0) { sport, sportTeams in + Section { + ForEach(sportTeams) { team in + Button { + if selectedTeamIds.contains(team.id) { + selectedTeamIds.remove(team.id) + } else { + selectedTeamIds.insert(team.id) + } + onChanged() + } label: { + HStack(spacing: Theme.Spacing.sm) { + Circle() + .fill(team.primaryColor.map { Color(hex: $0) } ?? sport.themeColor) + .frame(width: 28, height: 28) + + VStack(alignment: .leading, spacing: 2) { + Text(team.fullName) + .font(.body) + .foregroundStyle(Theme.textPrimary(colorScheme)) + + Text(team.city) + .font(.caption) + .foregroundStyle(Theme.textMuted(colorScheme)) + } + + Spacer() + + if selectedTeamIds.contains(team.id) { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(Theme.warmOrange) + } else { + Image(systemName: "circle") + .foregroundStyle(Theme.textMuted(colorScheme)) + } + } + .padding(.vertical, Theme.Spacing.xs) + } + .buttonStyle(.plain) + } + } header: { + HStack(spacing: Theme.Spacing.xs) { + Image(systemName: sport.iconName) + .foregroundStyle(sport.themeColor) + Text(sport.rawValue) + } + } + } + } + .listStyle(.plain) + .searchable(text: $searchText, prompt: "Search teams") + .navigationTitle("Select Teams") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + .fontWeight(.semibold) + } + } + } + .presentationDetents([.large]) + } +} + +// MARK: - Games Picker Sheet + +private struct GamesPickerSheet: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.colorScheme) private var colorScheme + + let selectedTeamIds: Set + @Binding var selectedGameIds: Set + + @State private var games: [RichGame] = [] + @State private var isLoading = true + + private var groupedGames: [(Date, [RichGame])] { + let grouped = Dictionary(grouping: games) { game in + Calendar.current.startOfDay(for: game.game.dateTime) + } + return grouped.keys.sorted().map { date in + (date, grouped[date] ?? []) + } + } + + var body: some View { + NavigationStack { + Group { + if isLoading { + ProgressView("Loading games...") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if games.isEmpty { + ContentUnavailableView( + "No Games Found", + systemImage: "ticket", + description: Text("No upcoming games found for the selected teams") + ) + } else { + List { + ForEach(groupedGames, id: \.0) { date, dateGames in + Section { + ForEach(dateGames) { game in + Button { + if selectedGameIds.contains(game.id) { + selectedGameIds.remove(game.id) + } else { + selectedGameIds.insert(game.id) + } + } label: { + HStack { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: Theme.Spacing.xs) { + Image(systemName: game.game.sport.iconName) + .font(.caption) + .foregroundStyle(game.game.sport.themeColor) + + Text(game.matchupDescription) + .font(.body) + .foregroundStyle(Theme.textPrimary(colorScheme)) + } + + Text("\(game.stadium.name) • \(game.localGameTimeShort)") + .font(.caption) + .foregroundStyle(Theme.textMuted(colorScheme)) + } + + Spacer() + + if selectedGameIds.contains(game.id) { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(Theme.warmOrange) + } else { + Image(systemName: "circle") + .foregroundStyle(Theme.textMuted(colorScheme)) + } + } + .padding(.vertical, Theme.Spacing.xs) + } + .buttonStyle(.plain) + } + } header: { + Text(date, style: .date) + } + } + } + .listStyle(.plain) + } + } + .navigationTitle("Select Games") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + .fontWeight(.semibold) + } + } + } + .presentationDetents([.large]) + .task { + await loadGames() + } + } + + private func loadGames() async { + var allGames: [RichGame] = [] + for teamId in selectedTeamIds { + if let teamGames = try? await AppDataProvider.shared.gamesForTeam(teamId: teamId) { + let futureGames = teamGames.filter { $0.game.dateTime > Date() } + allGames.append(contentsOf: futureGames) + } + } + + let uniqueGames = Array(Set(allGames)).sorted { $0.game.dateTime < $1.game.dateTime } + await MainActor.run { + games = uniqueGames + isLoading = false + } + } +} + +// MARK: - Preview + +#Preview { + GamePickerStep( + selectedSports: .constant([.mlb]), + selectedTeamIds: .constant([]), + selectedGameIds: .constant([]) + ) + .padding() + .themedBackground() +} diff --git a/SportsTime/Features/Trip/Views/Wizard/Steps/LocationSearchSheet.swift b/SportsTime/Features/Trip/Views/Wizard/Steps/LocationSearchSheet.swift index dc83435..2cbc3d2 100644 --- a/SportsTime/Features/Trip/Views/Wizard/Steps/LocationSearchSheet.swift +++ b/SportsTime/Features/Trip/Views/Wizard/Steps/LocationSearchSheet.swift @@ -31,6 +31,16 @@ struct LocationSearchSheet: View { private let locationService = LocationService.shared + private var navigationTitle: String { + switch inputType { + case .mustStop: return "Add Must-Stop" + case .preferred: return "Add Preferred Location" + case .homeLocation: return "Set Home Location" + case .startLocation: return "Set Start Location" + case .endLocation: return "Set End Location" + } + } + var body: some View { NavigationStack { VStack(spacing: 0) { @@ -96,7 +106,7 @@ struct LocationSearchSheet: View { Spacer() } - .navigationTitle(inputType == .mustStop ? "Add Must-Stop" : "Add Location") + .navigationTitle(navigationTitle) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { diff --git a/SportsTime/Features/Trip/Views/Wizard/Steps/LocationsStep.swift b/SportsTime/Features/Trip/Views/Wizard/Steps/LocationsStep.swift new file mode 100644 index 0000000..024bc09 --- /dev/null +++ b/SportsTime/Features/Trip/Views/Wizard/Steps/LocationsStep.swift @@ -0,0 +1,175 @@ +// +// LocationsStep.swift +// SportsTime +// +// Start and end location selection for "By Route" planning mode. +// + +import SwiftUI + +struct LocationsStep: View { + @Environment(\.colorScheme) private var colorScheme + + @Binding var startLocation: LocationInput? + @Binding var endLocation: LocationInput? + + @State private var showStartLocationSearch = false + @State private var showEndLocationSearch = false + @State private var isRoundTrip = false + + var body: some View { + VStack(alignment: .leading, spacing: Theme.Spacing.md) { + StepHeader( + title: "Where are you traveling?", + subtitle: "Set your start and end points" + ) + + // Start Location + locationRow( + label: "Starting from", + location: startLocation, + placeholder: "Select start city", + onTap: { showStartLocationSearch = true }, + onClear: { startLocation = nil } + ) + + // End Location + if !isRoundTrip { + locationRow( + label: "Ending at", + location: endLocation, + placeholder: "Select end city", + onTap: { showEndLocationSearch = true }, + onClear: { endLocation = nil } + ) + } + + // Round Trip Toggle + Toggle(isOn: $isRoundTrip) { + HStack(spacing: Theme.Spacing.sm) { + Image(systemName: "arrow.triangle.2.circlepath") + .foregroundStyle(Theme.warmOrange) + Text("Round trip (return to start)") + .font(.subheadline) + .foregroundStyle(Theme.textPrimary(colorScheme)) + } + } + .toggleStyle(SwitchToggleStyle(tint: Theme.warmOrange)) + .onChange(of: isRoundTrip) { _, newValue in + if newValue { + endLocation = startLocation + } else { + endLocation = nil + } + } + .onChange(of: startLocation) { _, newValue in + if isRoundTrip { + endLocation = newValue + } + } + } + .padding(Theme.Spacing.lg) + .background(Theme.cardBackground(colorScheme)) + .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large)) + .overlay { + RoundedRectangle(cornerRadius: Theme.CornerRadius.large) + .stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1) + } + .sheet(isPresented: $showStartLocationSearch) { + LocationSearchSheet(inputType: .startLocation) { location in + startLocation = location + } + } + .sheet(isPresented: $showEndLocationSearch) { + LocationSearchSheet(inputType: .endLocation) { location in + endLocation = location + } + } + } + + // MARK: - Location Row + + private func locationRow( + label: String, + location: LocationInput?, + placeholder: String, + onTap: @escaping () -> Void, + onClear: @escaping () -> Void + ) -> some View { + VStack(alignment: .leading, spacing: Theme.Spacing.xs) { + Text(label) + .font(.subheadline) + .fontWeight(.medium) + .foregroundStyle(Theme.textSecondary(colorScheme)) + + if let location = location { + // Selected location + HStack { + Image(systemName: "mappin.circle.fill") + .foregroundStyle(Theme.warmOrange) + + VStack(alignment: .leading, spacing: 2) { + Text(location.name) + .font(.subheadline) + .fontWeight(.medium) + .foregroundStyle(Theme.textPrimary(colorScheme)) + + if let address = location.address, !address.isEmpty { + Text(address) + .font(.caption) + .foregroundStyle(Theme.textMuted(colorScheme)) + .lineLimit(1) + } + } + + Spacer() + + Button(action: onClear) { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(Theme.textMuted(colorScheme)) + } + } + .padding(Theme.Spacing.sm) + .background(Theme.cardBackgroundElevated(colorScheme)) + .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) + } else { + // Empty state - tap to add + Button(action: onTap) { + HStack { + Image(systemName: "plus.circle") + .foregroundStyle(Theme.warmOrange) + + Text(placeholder) + .font(.subheadline) + .foregroundStyle(Theme.textMuted(colorScheme)) + + Spacer() + + Image(systemName: "chevron.right") + .font(.caption) + .foregroundStyle(Theme.textMuted(colorScheme)) + } + .padding(Theme.Spacing.sm) + .background(Theme.cardBackgroundElevated(colorScheme)) + .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) + .overlay( + RoundedRectangle(cornerRadius: Theme.CornerRadius.medium) + .stroke(Theme.textMuted(colorScheme).opacity(0.3), lineWidth: 1) + ) + } + .buttonStyle(.plain) + } + } + } +} + +// MARK: - Preview + +#Preview { + LocationsStep( + startLocation: .constant(nil), + endLocation: .constant(nil) + ) + .padding() + .themedBackground() +} diff --git a/SportsTime/Features/Trip/Views/Wizard/Steps/ReviewStep.swift b/SportsTime/Features/Trip/Views/Wizard/Steps/ReviewStep.swift index fcc2fd6..c711d5e 100644 --- a/SportsTime/Features/Trip/Views/Wizard/Steps/ReviewStep.swift +++ b/SportsTime/Features/Trip/Views/Wizard/Steps/ReviewStep.swift @@ -22,6 +22,12 @@ struct ReviewStep: View { let fieldValidation: FieldValidation let onPlan: () -> Void + // Mode-specific display values (passed from parent) + var selectedGameCount: Int = 0 + var selectedTeamName: String? = nil + var startLocationName: String? = nil + var endLocationName: String? = nil + var body: some View { VStack(alignment: .leading, spacing: Theme.Spacing.md) { StepHeader( @@ -30,33 +36,19 @@ struct ReviewStep: View { ) VStack(alignment: .leading, spacing: Theme.Spacing.sm) { + // Mode (always shown) ReviewRow(label: "Mode", value: planningMode.displayName) - ReviewRow( - label: "Sports", - value: selectedSports.isEmpty ? "Not selected" : selectedSports.map(\.rawValue).sorted().joined(separator: ", "), - isMissing: fieldValidation.sports == .missing - ) - ReviewRow( - label: "Dates", - value: dateRangeText, - isMissing: fieldValidation.dates == .missing - ) - ReviewRow( - label: "Regions", - value: selectedRegions.isEmpty ? "Not selected" : selectedRegions.map(\.shortName).sorted().joined(separator: ", "), - isMissing: fieldValidation.regions == .missing - ) - ReviewRow( - label: "Route", - value: routePreference.displayName, - isMissing: fieldValidation.routePreference == .missing - ) - ReviewRow( - label: "Repeat cities", - value: allowRepeatCities ? "Yes" : "No", - isMissing: fieldValidation.repeatCities == .missing - ) + // Mode-specific required fields + ForEach(fieldValidation.requiredFields, id: \.name) { field in + ReviewRow( + label: field.name, + value: displayValue(for: field.name), + isMissing: field.status == .missing + ) + } + + // Optional: Must-stops (shown if any selected) if !mustStopLocations.isEmpty { ReviewRow(label: "Must-stops", value: mustStopLocations.map(\.name).joined(separator: ", ")) } @@ -65,6 +57,17 @@ struct ReviewStep: View { .background(Theme.cardBackgroundElevated(colorScheme)) .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) + // Missing fields warning + if !fieldValidation.missingFields.isEmpty { + HStack(spacing: Theme.Spacing.xs) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + Text("Complete all required fields to continue") + .font(.caption) + .foregroundStyle(Theme.textMuted(colorScheme)) + } + } + Button(action: onPlan) { HStack(spacing: Theme.Spacing.sm) { if isPlanning { @@ -91,6 +94,31 @@ struct ReviewStep: View { } } + private func displayValue(for fieldName: String) -> String { + switch fieldName { + case "Sports": + return selectedSports.isEmpty ? "Not selected" : selectedSports.map(\.rawValue).sorted().joined(separator: ", ") + case "Dates": + return dateRangeText + case "Regions": + return selectedRegions.isEmpty ? "Not selected" : selectedRegions.map(\.shortName).sorted().joined(separator: ", ") + case "Route Preference": + return routePreference.displayName + case "Repeat Cities": + return allowRepeatCities ? "Yes" : "No" + case "Games": + return selectedGameCount > 0 ? "\(selectedGameCount) game\(selectedGameCount == 1 ? "" : "s") selected" : "Not selected" + case "Team": + return selectedTeamName ?? "Not selected" + case "Start Location": + return startLocationName ?? "Not selected" + case "End Location": + return endLocationName ?? "Not selected" + default: + return "—" + } + } + private var dateRangeText: String { let formatter = DateFormatter() formatter.dateStyle = .medium @@ -143,11 +171,16 @@ private struct ReviewRow: View { isPlanning: false, canPlanTrip: true, fieldValidation: FieldValidation( + planningMode: .dateRange, sports: .valid, dates: .valid, regions: .valid, routePreference: .valid, - repeatCities: .valid + repeatCities: .valid, + selectedGames: .valid, + selectedTeam: .valid, + startLocation: .valid, + endLocation: .valid ), onPlan: {} ) @@ -168,11 +201,16 @@ private struct ReviewRow: View { isPlanning: false, canPlanTrip: false, fieldValidation: FieldValidation( + planningMode: .dateRange, sports: .missing, dates: .valid, regions: .missing, routePreference: .valid, - repeatCities: .missing + repeatCities: .missing, + selectedGames: .valid, + selectedTeam: .valid, + startLocation: .valid, + endLocation: .valid ), onPlan: {} ) diff --git a/SportsTime/Features/Trip/Views/Wizard/Steps/TeamPickerStep.swift b/SportsTime/Features/Trip/Views/Wizard/Steps/TeamPickerStep.swift new file mode 100644 index 0000000..f0da7fd --- /dev/null +++ b/SportsTime/Features/Trip/Views/Wizard/Steps/TeamPickerStep.swift @@ -0,0 +1,240 @@ +// +// TeamPickerStep.swift +// SportsTime +// +// Team selection step for "Follow Team" planning mode. +// Uses sheet-based drill-down: Sport → Team. +// + +import SwiftUI + +struct TeamPickerStep: View { + @Environment(\.colorScheme) private var colorScheme + + @Binding var selectedSport: Sport? + @Binding var selectedTeamId: String? + + @State private var showTeamPicker = false + + private var selectedTeam: Team? { + guard let teamId = selectedTeamId else { return nil } + return AppDataProvider.shared.teams.first { $0.id == teamId } + } + + var body: some View { + VStack(alignment: .leading, spacing: Theme.Spacing.md) { + StepHeader( + title: "Which team do you want to follow?", + subtitle: "See their home and away games" + ) + + // Selection button + Button { + showTeamPicker = true + } label: { + HStack { + if let team = selectedTeam { + // Show selected team + Circle() + .fill(team.primaryColor.map { Color(hex: $0) } ?? team.sport.themeColor) + .frame(width: 24, height: 24) + + VStack(alignment: .leading, spacing: 2) { + Text(team.fullName) + .font(.subheadline) + .fontWeight(.medium) + .foregroundStyle(Theme.textPrimary(colorScheme)) + + Text(team.sport.rawValue) + .font(.caption) + .foregroundStyle(Theme.textMuted(colorScheme)) + } + + Spacer() + + Button { + selectedTeamId = nil + selectedSport = nil + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(Theme.textMuted(colorScheme)) + } + } else { + // Empty state + Image(systemName: "person.2.fill") + .foregroundStyle(Theme.warmOrange) + + Text("Select a team") + .font(.subheadline) + .foregroundStyle(Theme.textMuted(colorScheme)) + + Spacer() + + Image(systemName: "chevron.right") + .font(.caption) + .foregroundStyle(Theme.textMuted(colorScheme)) + } + } + .padding(Theme.Spacing.md) + .background(Theme.cardBackgroundElevated(colorScheme)) + .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) + .overlay( + RoundedRectangle(cornerRadius: Theme.CornerRadius.medium) + .stroke(selectedTeam != nil ? Theme.warmOrange : Theme.textMuted(colorScheme).opacity(0.3), lineWidth: selectedTeam != nil ? 2 : 1) + ) + } + .buttonStyle(.plain) + } + .padding(Theme.Spacing.lg) + .background(Theme.cardBackground(colorScheme)) + .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large)) + .overlay { + RoundedRectangle(cornerRadius: Theme.CornerRadius.large) + .stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1) + } + .sheet(isPresented: $showTeamPicker) { + TeamPickerSheet( + selectedSport: $selectedSport, + selectedTeamId: $selectedTeamId + ) + } + } +} + +// MARK: - Team Picker Sheet + +private struct TeamPickerSheet: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.colorScheme) private var colorScheme + + @Binding var selectedSport: Sport? + @Binding var selectedTeamId: String? + + var body: some View { + NavigationStack { + List { + ForEach(Sport.supported, id: \.self) { sport in + NavigationLink { + TeamListView( + sport: sport, + selectedTeamId: $selectedTeamId, + onSelect: { teamId in + selectedSport = sport + selectedTeamId = teamId + dismiss() + } + ) + } label: { + HStack(spacing: Theme.Spacing.sm) { + Image(systemName: sport.iconName) + .font(.title2) + .foregroundStyle(sport.themeColor) + .frame(width: 32) + + Text(sport.rawValue) + .font(.body) + .foregroundStyle(Theme.textPrimary(colorScheme)) + + Spacer() + + Text("\(teamsCount(for: sport)) teams") + .font(.caption) + .foregroundStyle(Theme.textMuted(colorScheme)) + } + .padding(.vertical, Theme.Spacing.xs) + } + } + } + .listStyle(.plain) + .navigationTitle("Select Sport") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + } + } + .presentationDetents([.large]) + } + + private func teamsCount(for sport: Sport) -> Int { + AppDataProvider.shared.teams.filter { $0.sport == sport }.count + } +} + +// MARK: - Team List View + +private struct TeamListView: View { + @Environment(\.colorScheme) private var colorScheme + + let sport: Sport + @Binding var selectedTeamId: String? + let onSelect: (String) -> Void + + @State private var searchText = "" + + private var teams: [Team] { + let allTeams = AppDataProvider.shared.teams + .filter { $0.sport == sport } + .sorted { $0.fullName < $1.fullName } + + if searchText.isEmpty { + return allTeams + } + + return allTeams.filter { + $0.fullName.localizedCaseInsensitiveContains(searchText) || + $0.city.localizedCaseInsensitiveContains(searchText) + } + } + + var body: some View { + List { + ForEach(teams) { team in + Button { + onSelect(team.id) + } label: { + HStack(spacing: Theme.Spacing.sm) { + Circle() + .fill(team.primaryColor.map { Color(hex: $0) } ?? sport.themeColor) + .frame(width: 28, height: 28) + + VStack(alignment: .leading, spacing: 2) { + Text(team.fullName) + .font(.body) + .foregroundStyle(Theme.textPrimary(colorScheme)) + + Text(team.city) + .font(.caption) + .foregroundStyle(Theme.textMuted(colorScheme)) + } + + Spacer() + + if selectedTeamId == team.id { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(Theme.warmOrange) + } + } + .padding(.vertical, Theme.Spacing.xs) + } + .buttonStyle(.plain) + } + } + .listStyle(.plain) + .searchable(text: $searchText, prompt: "Search teams") + .navigationTitle(sport.rawValue) + .navigationBarTitleDisplayMode(.inline) + } +} + +// MARK: - Preview + +#Preview { + TeamPickerStep( + selectedSport: .constant(.mlb), + selectedTeamId: .constant(nil) + ) + .padding() + .themedBackground() +} diff --git a/SportsTime/Features/Trip/Views/Wizard/TripWizardView.swift b/SportsTime/Features/Trip/Views/Wizard/TripWizardView.swift index 7848e4e..8837b51 100644 --- a/SportsTime/Features/Trip/Views/Wizard/TripWizardView.swift +++ b/SportsTime/Features/Trip/Views/Wizard/TripWizardView.swift @@ -19,6 +19,12 @@ struct TripWizardView: View { private let planningEngine = TripPlanningEngine() + /// Selected team name for display in ReviewStep + private var selectedTeamName: String? { + guard let teamId = viewModel.selectedTeamId else { return nil } + return AppDataProvider.shared.teams.first { $0.id == teamId }?.fullName + } + var body: some View { NavigationStack { ScrollView { @@ -29,26 +35,57 @@ struct TripWizardView: View { // All other steps appear together after planning mode selected if viewModel.areStepsVisible { Group { - DatesStep( - startDate: $viewModel.startDate, - endDate: $viewModel.endDate, - hasSetDates: $viewModel.hasSetDates, - onDatesChanged: { - Task { - await viewModel.fetchSportAvailability() + // Mode-specific steps + if viewModel.showGamePickerStep { + GamePickerStep( + selectedSports: $viewModel.gamePickerSports, + selectedTeamIds: $viewModel.gamePickerTeamIds, + selectedGameIds: $viewModel.selectedGameIds + ) + } + + if viewModel.showTeamPickerStep { + TeamPickerStep( + selectedSport: $viewModel.teamPickerSport, + selectedTeamId: $viewModel.selectedTeamId + ) + } + + if viewModel.showLocationsStep { + LocationsStep( + startLocation: $viewModel.startLocation, + endLocation: $viewModel.endLocation + ) + } + + // Common steps (conditionally shown) + if viewModel.showDatesStep { + DatesStep( + startDate: $viewModel.startDate, + endDate: $viewModel.endDate, + hasSetDates: $viewModel.hasSetDates, + onDatesChanged: { + Task { + await viewModel.fetchSportAvailability() + } } - } - ) + ) + } - SportsStep( - selectedSports: $viewModel.selectedSports, - sportAvailability: viewModel.sportAvailability, - isLoading: viewModel.isLoadingSportAvailability, - canSelectSport: viewModel.canSelectSport - ) + if viewModel.showSportsStep { + SportsStep( + selectedSports: $viewModel.selectedSports, + sportAvailability: viewModel.sportAvailability, + isLoading: viewModel.isLoadingSportAvailability, + canSelectSport: viewModel.canSelectSport + ) + } - RegionsStep(selectedRegions: $viewModel.selectedRegions) + if viewModel.showRegionsStep { + RegionsStep(selectedRegions: $viewModel.selectedRegions) + } + // Always shown steps RoutePreferenceStep( routePreference: $viewModel.routePreference, hasSetRoutePreference: $viewModel.hasSetRoutePreference @@ -73,7 +110,11 @@ struct TripWizardView: View { isPlanning: viewModel.isPlanning, canPlanTrip: viewModel.canPlanTrip, fieldValidation: viewModel.fieldValidation, - onPlan: { Task { await planTrip() } } + onPlan: { Task { await planTrip() } }, + selectedGameCount: viewModel.selectedGameIds.count, + selectedTeamName: selectedTeamName, + startLocationName: viewModel.startLocation?.name, + endLocationName: viewModel.endLocation?.name ) } .transition(.opacity) @@ -115,19 +156,61 @@ struct TripWizardView: View { defer { viewModel.isPlanning = false } do { - let preferences = buildPreferences() - - // Fetch games for selected sports and date range - let games = try await AppDataProvider.shared.filterGames( - sports: preferences.sports, - startDate: preferences.startDate, - endDate: preferences.endDate - ) + var preferences = buildPreferences() // Build dictionaries from arrays let teamsById = Dictionary(uniqueKeysWithValues: AppDataProvider.shared.teams.map { ($0.id, $0) }) let stadiumsById = Dictionary(uniqueKeysWithValues: AppDataProvider.shared.stadiums.map { ($0.id, $0) }) + // For gameFirst mode, derive date range from selected games + var games: [Game] + if viewModel.planningMode == .gameFirst && !viewModel.selectedGameIds.isEmpty { + // Fetch all games for the selected sports to find the must-see games + let allGames = try await AppDataProvider.shared.allGames(for: preferences.sports) + + // Find the selected must-see games + let mustSeeGames = allGames.filter { viewModel.selectedGameIds.contains($0.id) } + + if mustSeeGames.isEmpty { + planningError = "Could not find the selected games. Please try again." + showError = true + return + } + + // Derive date range from must-see games (with buffer) + let gameDates = mustSeeGames.map { $0.dateTime } + let minDate = gameDates.min() ?? Date() + let maxDate = gameDates.max() ?? Date() + + // Update preferences with derived date range + preferences = TripPreferences( + planningMode: preferences.planningMode, + startLocation: preferences.startLocation, + endLocation: preferences.endLocation, + sports: preferences.sports, + mustSeeGameIds: preferences.mustSeeGameIds, + startDate: Calendar.current.startOfDay(for: minDate), + endDate: Calendar.current.date(byAdding: .day, value: 1, to: maxDate) ?? maxDate, + mustStopLocations: preferences.mustStopLocations, + routePreference: preferences.routePreference, + allowRepeatCities: preferences.allowRepeatCities, + selectedRegions: preferences.selectedRegions, + followTeamId: preferences.followTeamId + ) + + // Use all games within the derived date range + games = allGames.filter { + $0.dateTime >= preferences.startDate && $0.dateTime <= preferences.endDate + } + } else { + // Standard mode: fetch games for date range + games = try await AppDataProvider.shared.filterGames( + sports: preferences.sports, + startDate: preferences.startDate, + endDate: preferences.endDate + ) + } + // Build RichGame dictionary for display var richGamesDict: [String: RichGame] = [:] for game in games { @@ -171,15 +254,29 @@ struct TripWizardView: View { } private func buildPreferences() -> TripPreferences { - TripPreferences( + // Determine which sports to use based on mode + let sports: Set + if viewModel.planningMode == .gameFirst { + sports = viewModel.gamePickerSports + } else if viewModel.planningMode == .followTeam, let sport = viewModel.teamPickerSport { + sports = [sport] + } else { + sports = viewModel.selectedSports + } + + return TripPreferences( planningMode: viewModel.planningMode ?? .dateRange, - sports: viewModel.selectedSports, + startLocation: viewModel.startLocation, + endLocation: viewModel.endLocation, + sports: sports, + mustSeeGameIds: viewModel.selectedGameIds, startDate: viewModel.startDate, endDate: viewModel.endDate, mustStopLocations: viewModel.mustStopLocations, routePreference: viewModel.routePreference, allowRepeatCities: viewModel.allowRepeatCities, - selectedRegions: viewModel.selectedRegions + selectedRegions: viewModel.selectedRegions, + followTeamId: viewModel.selectedTeamId ) }