feat: add Follow Team Mode (Scenario D) for road trip planning
Adds a new planning mode that lets users follow a team's schedule (home + away games) and builds multi-city routes accordingly. Key changes: - New ScenarioDPlanner with team filtering and route generation - Team picker UI with sport grouping and search - Fix TravelEstimator 5-day limit (was 2-day) for cross-country routes - Fix DateInterval end boundary to include games on last day - Comprehensive test suite covering edge cases: - Multi-city routes with adequate/insufficient time - Optimal game selection per city for feasibility - 5-day driving segment limits - Multiple driver scenarios Enables trips like Houston → Chicago → Anaheim following the Astros. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -73,6 +73,12 @@ struct TripCreationView: View {
|
||||
sportsSection
|
||||
datesSection
|
||||
gamesSection
|
||||
|
||||
case .followTeam:
|
||||
// Team picker + Dates + Home location toggle
|
||||
teamPickerSection
|
||||
datesSection
|
||||
homeLocationSection
|
||||
}
|
||||
|
||||
// Common sections
|
||||
@@ -516,6 +522,113 @@ struct TripCreationView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Follow Team Mode
|
||||
|
||||
@State private var showTeamPicker = false
|
||||
|
||||
private var teamPickerSection: some View {
|
||||
ThemedSection(title: "Select Team") {
|
||||
Button {
|
||||
showTeamPicker = true
|
||||
} label: {
|
||||
HStack(spacing: Theme.Spacing.md) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Theme.warmOrange.opacity(0.15))
|
||||
.frame(width: 40, height: 40)
|
||||
Image(systemName: "person.3.fill")
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
if let team = viewModel.followedTeam {
|
||||
Text(team.name)
|
||||
.font(.body)
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
Text(team.sport.displayName)
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
} else {
|
||||
Text("Choose a team")
|
||||
.font(.body)
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
Text("Pick the team to follow")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
}
|
||||
.padding(Theme.Spacing.md)
|
||||
.background(Theme.cardBackgroundElevated(colorScheme))
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.sheet(isPresented: $showTeamPicker) {
|
||||
TeamPickerSheet(
|
||||
selectedTeamId: $viewModel.followTeamId,
|
||||
teamsBySport: viewModel.teamsBySport
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var homeLocationSection: some View {
|
||||
ThemedSection(title: "Trip Start/End") {
|
||||
VStack(spacing: Theme.Spacing.md) {
|
||||
ThemedToggle(
|
||||
label: "Start and end from home",
|
||||
isOn: $viewModel.useHomeLocation,
|
||||
icon: "house.fill"
|
||||
)
|
||||
|
||||
if viewModel.useHomeLocation {
|
||||
// Show home location input with suggestions
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ThemedTextField(
|
||||
label: "Home Location",
|
||||
placeholder: "Enter your city",
|
||||
text: $viewModel.startLocationText,
|
||||
icon: "house.fill"
|
||||
)
|
||||
.onChange(of: viewModel.startLocationText) { _, newValue in
|
||||
searchLocation(query: newValue, isStart: true)
|
||||
}
|
||||
|
||||
// Suggestions for home location
|
||||
if !startLocationSuggestions.isEmpty {
|
||||
locationSuggestionsList(
|
||||
suggestions: startLocationSuggestions,
|
||||
isLoading: isSearchingStart
|
||||
) { result in
|
||||
viewModel.startLocationText = result.name
|
||||
viewModel.startLocation = result.toLocationInput()
|
||||
startLocationSuggestions = []
|
||||
}
|
||||
} else if isSearchingStart {
|
||||
HStack {
|
||||
ThemedSpinnerCompact(size: 14)
|
||||
Text("Searching...")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
}
|
||||
.padding(.top, Theme.Spacing.xs)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text("Trip will start at first game and end at last game (fly-in/fly-out)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
.padding(.leading, 32)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var gamesSection: some View {
|
||||
ThemedSection(title: "Must-See Games") {
|
||||
Button {
|
||||
@@ -2205,6 +2318,114 @@ struct DayCell: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Team Picker Sheet
|
||||
|
||||
struct TeamPickerSheet: View {
|
||||
@Binding var selectedTeamId: UUID?
|
||||
let teamsBySport: [Sport: [Team]]
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
@State private var searchText = ""
|
||||
|
||||
private var sortedSports: [Sport] {
|
||||
Sport.allCases.filter { teamsBySport[$0] != nil && !teamsBySport[$0]!.isEmpty }
|
||||
}
|
||||
|
||||
private var filteredTeamsBySport: [Sport: [Team]] {
|
||||
guard !searchText.isEmpty else { return teamsBySport }
|
||||
|
||||
var filtered: [Sport: [Team]] = [:]
|
||||
for (sport, teams) in teamsBySport {
|
||||
let matchingTeams = teams.filter {
|
||||
$0.name.localizedCaseInsensitiveContains(searchText) ||
|
||||
$0.city.localizedCaseInsensitiveContains(searchText) ||
|
||||
$0.abbreviation.localizedCaseInsensitiveContains(searchText)
|
||||
}
|
||||
if !matchingTeams.isEmpty {
|
||||
filtered[sport] = matchingTeams
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
ForEach(sortedSports, id: \.self) { sport in
|
||||
if let teams = filteredTeamsBySport[sport], !teams.isEmpty {
|
||||
Section(sport.displayName) {
|
||||
ForEach(teams) { team in
|
||||
TeamRow(
|
||||
team: team,
|
||||
isSelected: selectedTeamId == team.id,
|
||||
colorScheme: colorScheme
|
||||
) {
|
||||
selectedTeamId = team.id
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.searchable(text: $searchText, prompt: "Search teams")
|
||||
.navigationTitle("Select Team")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TeamRow: View {
|
||||
let team: Team
|
||||
let isSelected: Bool
|
||||
let colorScheme: ColorScheme
|
||||
let onTap: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: onTap) {
|
||||
HStack(spacing: Theme.Spacing.md) {
|
||||
// Team color indicator
|
||||
if let colorHex = team.primaryColor {
|
||||
Circle()
|
||||
.fill(Color(hex: colorHex))
|
||||
.frame(width: 12, height: 12)
|
||||
} else {
|
||||
Circle()
|
||||
.fill(Theme.warmOrange)
|
||||
.frame(width: 12, height: 12)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(team.name)
|
||||
.font(.body)
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
Text(team.city)
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if isSelected {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
TripCreationView(viewModel: TripCreationViewModel())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user