feat: rewrite bootstrap, fix CloudKit sync, update canonical data, and UI fixes
- Rewrite BootstrapService: remove all legacy code paths (JSONStadium, JSONGame, bootstrapStadiumsLegacy, bootstrapGamesLegacy, venue aliases, createDefaultLeagueStructure), require canonical JSON files only - Add clearCanonicalData() to handle partial bootstrap recovery (prevents duplicate key crashes from interrupted first-launch) - Fix nullable stadium_canonical_id in games (4 MLS games have null) - Fix CKModels: logoUrl case, conference/division field keys - Fix CanonicalSyncService: sync conferenceCanonicalId/divisionCanonicalId - Add sports_canonical.json and DemoMode.swift - Delete legacy stadiums.json and games.json - Update all canonical resource JSON files with latest data - Fix TripWizardView horizontal scrolling with GeometryReader constraint - Update RegionMapSelector, TripDetailView, TripOptionsView UI improvements - Add DateRangePicker, PlanningModeStep, SportsStep enhancements - Update UI tests and marketing-videos config Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,8 @@ struct RegionMapSelector: View {
|
||||
let onToggle: (Region) -> Void
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.isDemoMode) private var isDemoMode
|
||||
@State private var hasAppliedDemoSelection = false
|
||||
|
||||
// Camera position centered on continental US
|
||||
@State private var cameraPosition: MapCameraPosition = .camera(
|
||||
@@ -33,29 +35,44 @@ struct RegionMapSelector: View {
|
||||
var body: some View {
|
||||
VStack(spacing: Theme.Spacing.sm) {
|
||||
// Map with region overlays
|
||||
MapReader { proxy in
|
||||
Map(position: $cameraPosition, interactionModes: []) {
|
||||
// West region polygon
|
||||
MapPolygon(coordinates: RegionCoordinates.west)
|
||||
.foregroundStyle(fillColor(for: .west))
|
||||
.stroke(strokeColor(for: .west), lineWidth: strokeWidth(for: .west))
|
||||
ZStack {
|
||||
MapReader { proxy in
|
||||
Map(position: $cameraPosition, interactionModes: []) {
|
||||
// West region polygon
|
||||
MapPolygon(coordinates: RegionCoordinates.west)
|
||||
.foregroundStyle(fillColor(for: .west))
|
||||
.stroke(strokeColor(for: .west), lineWidth: strokeWidth(for: .west))
|
||||
|
||||
// Central region polygon
|
||||
MapPolygon(coordinates: RegionCoordinates.central)
|
||||
.foregroundStyle(fillColor(for: .central))
|
||||
.stroke(strokeColor(for: .central), lineWidth: strokeWidth(for: .central))
|
||||
// Central region polygon
|
||||
MapPolygon(coordinates: RegionCoordinates.central)
|
||||
.foregroundStyle(fillColor(for: .central))
|
||||
.stroke(strokeColor(for: .central), lineWidth: strokeWidth(for: .central))
|
||||
|
||||
// East region polygon
|
||||
MapPolygon(coordinates: RegionCoordinates.east)
|
||||
.foregroundStyle(fillColor(for: .east))
|
||||
.stroke(strokeColor(for: .east), lineWidth: strokeWidth(for: .east))
|
||||
}
|
||||
.mapStyle(.standard(elevation: .flat, pointsOfInterest: .excludingAll))
|
||||
.onTapGesture { location in
|
||||
if let coordinate = proxy.convert(location, from: .local) {
|
||||
let tappedRegion = regionForCoordinate(coordinate)
|
||||
onToggle(tappedRegion)
|
||||
// East region polygon
|
||||
MapPolygon(coordinates: RegionCoordinates.east)
|
||||
.foregroundStyle(fillColor(for: .east))
|
||||
.stroke(strokeColor(for: .east), lineWidth: strokeWidth(for: .east))
|
||||
}
|
||||
.mapStyle(.standard(elevation: .flat, pointsOfInterest: .excludingAll))
|
||||
.onTapGesture { location in
|
||||
if let coordinate = proxy.convert(location, from: .local) {
|
||||
let tappedRegion = regionForCoordinate(coordinate)
|
||||
onToggle(tappedRegion)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Invisible button overlays for UI testing accessibility
|
||||
HStack(spacing: 0) {
|
||||
Button { onToggle(.west) } label: { Color.clear }
|
||||
.accessibilityIdentifier("wizard.regions.west")
|
||||
.frame(maxWidth: .infinity)
|
||||
Button { onToggle(.central) } label: { Color.clear }
|
||||
.accessibilityIdentifier("wizard.regions.central")
|
||||
.frame(maxWidth: .infinity)
|
||||
Button { onToggle(.east) } label: { Color.clear }
|
||||
.accessibilityIdentifier("wizard.regions.east")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.frame(height: 160)
|
||||
@@ -71,6 +88,14 @@ struct RegionMapSelector: View {
|
||||
// Selection footer
|
||||
selectionFooter
|
||||
}
|
||||
.onAppear {
|
||||
if isDemoMode && !hasAppliedDemoSelection && selectedRegions.isEmpty {
|
||||
hasAppliedDemoSelection = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + DemoConfig.selectionDelay) {
|
||||
onToggle(DemoConfig.demoRegion)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Coordinate to Region
|
||||
|
||||
@@ -12,6 +12,7 @@ import UniformTypeIdentifiers
|
||||
struct TripDetailView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.isDemoMode) private var isDemoMode
|
||||
|
||||
let trip: Trip
|
||||
private let providedGames: [String: RichGame]?
|
||||
@@ -33,6 +34,7 @@ struct TripDetailView: View {
|
||||
@State private var isLoadingRoutes = false
|
||||
@State private var loadedGames: [String: RichGame] = [:]
|
||||
@State private var isLoadingGames = false
|
||||
@State private var hasAppliedDemoSelection = false
|
||||
|
||||
// Itinerary items state
|
||||
@State private var itineraryItems: [ItineraryItem] = []
|
||||
@@ -113,7 +115,18 @@ struct TripDetailView: View {
|
||||
} message: {
|
||||
Text("This trip has \(multiRouteChunks.flatMap { $0 }.count) stops, which exceeds Apple Maps' limit of 16. Open the route in parts?")
|
||||
}
|
||||
.onAppear { checkIfSaved() }
|
||||
.onAppear {
|
||||
checkIfSaved()
|
||||
// Demo mode: auto-favorite the trip
|
||||
if isDemoMode && !hasAppliedDemoSelection && !isSaved {
|
||||
hasAppliedDemoSelection = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + DemoConfig.selectionDelay + 0.5) {
|
||||
if !isSaved {
|
||||
saveTrip()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await loadGamesIfNeeded()
|
||||
if allowCustomItems {
|
||||
@@ -348,6 +361,7 @@ struct TripDetailView: View {
|
||||
.clipShape(Circle())
|
||||
.shadow(color: .black.opacity(0.2), radius: 4, y: 2)
|
||||
}
|
||||
.accessibilityIdentifier("tripDetail.favoriteButton")
|
||||
.padding(.top, 12)
|
||||
.padding(.trailing, 12)
|
||||
}
|
||||
|
||||
@@ -143,6 +143,8 @@ struct TripOptionsView: View {
|
||||
@State private var citiesFilter: CitiesFilter = .noLimit
|
||||
@State private var paceFilter: TripPaceFilter = .all
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.isDemoMode) private var isDemoMode
|
||||
@State private var hasAppliedDemoSelection = false
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
@@ -272,7 +274,7 @@ struct TripOptionsView: View {
|
||||
}
|
||||
|
||||
// Options in this group
|
||||
ForEach(group.options) { option in
|
||||
ForEach(Array(group.options.enumerated()), id: \.element.id) { index, option in
|
||||
TripOptionCard(
|
||||
option: option,
|
||||
games: games,
|
||||
@@ -281,6 +283,7 @@ struct TripOptionsView: View {
|
||||
showTripDetail = true
|
||||
}
|
||||
)
|
||||
.accessibilityIdentifier("tripOptions.trip.\(index)")
|
||||
.padding(.horizontal, Theme.Spacing.md)
|
||||
}
|
||||
}
|
||||
@@ -300,6 +303,26 @@ struct TripOptionsView: View {
|
||||
selectedTrip = nil
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if isDemoMode && !hasAppliedDemoSelection {
|
||||
hasAppliedDemoSelection = true
|
||||
// Auto-select "Most Games" sort after a delay
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + DemoConfig.selectionDelay) {
|
||||
withAnimation(.easeInOut(duration: 0.3)) {
|
||||
sortOption = DemoConfig.demoSortOption
|
||||
}
|
||||
}
|
||||
// Then navigate to the 4th trip (index 3)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + DemoConfig.selectionDelay + 0.8) {
|
||||
let sortedOptions = filteredAndSortedOptions
|
||||
if sortedOptions.count > DemoConfig.demoTripIndex {
|
||||
let option = sortedOptions[DemoConfig.demoTripIndex]
|
||||
selectedTrip = convertToTrip(option)
|
||||
showTripDetail = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var sortPicker: some View {
|
||||
@@ -312,6 +335,7 @@ struct TripOptionsView: View {
|
||||
} label: {
|
||||
Label(option.rawValue, systemImage: option.icon)
|
||||
}
|
||||
.accessibilityIdentifier("tripOptions.sortOption.\(option.rawValue.lowercased().replacingOccurrences(of: " ", with: ""))")
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
@@ -332,6 +356,7 @@ struct TripOptionsView: View {
|
||||
.strokeBorder(Theme.textMuted(colorScheme).opacity(0.2), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.accessibilityIdentifier("tripOptions.sortDropdown")
|
||||
}
|
||||
|
||||
// MARK: - Filters Section
|
||||
|
||||
@@ -11,9 +11,11 @@ struct DateRangePicker: View {
|
||||
@Binding var startDate: Date
|
||||
@Binding var endDate: Date
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.isDemoMode) private var isDemoMode
|
||||
|
||||
@State private var displayedMonth: Date = Date()
|
||||
@State private var selectionState: SelectionState = .none
|
||||
@State private var hasAppliedDemoSelection = false
|
||||
|
||||
enum SelectionState {
|
||||
case none
|
||||
@@ -89,6 +91,24 @@ struct DateRangePicker: View {
|
||||
if endDate > startDate {
|
||||
selectionState = .complete
|
||||
}
|
||||
|
||||
// Demo mode: auto-select dates
|
||||
if isDemoMode && !hasAppliedDemoSelection {
|
||||
hasAppliedDemoSelection = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + DemoConfig.selectionDelay) {
|
||||
withAnimation(.easeInOut(duration: 0.3)) {
|
||||
// Navigate to demo month
|
||||
displayedMonth = DemoConfig.demoStartDate
|
||||
}
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + DemoConfig.selectionDelay + 0.5) {
|
||||
withAnimation(.easeInOut(duration: 0.3)) {
|
||||
startDate = DemoConfig.demoStartDate
|
||||
endDate = DemoConfig.demoEndDate
|
||||
selectionState = .complete
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: startDate) { oldValue, newValue in
|
||||
// Navigate calendar to show the new month when startDate changes externally
|
||||
@@ -159,12 +179,14 @@ struct DateRangePicker: View {
|
||||
.background(Theme.warmOrange.opacity(0.15))
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.accessibilityIdentifier("wizard.dates.previousMonth")
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(monthYearString)
|
||||
.font(.headline)
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
.accessibilityIdentifier("wizard.dates.monthLabel")
|
||||
|
||||
Spacer()
|
||||
|
||||
@@ -180,6 +202,7 @@ struct DateRangePicker: View {
|
||||
.background(Theme.warmOrange.opacity(0.15))
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.accessibilityIdentifier("wizard.dates.nextMonth")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,6 +310,12 @@ struct DayCell: View {
|
||||
calendar.startOfDay(for: date) < calendar.startOfDay(for: Date())
|
||||
}
|
||||
|
||||
private var accessibilityId: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
return "wizard.dates.day.\(formatter.string(from: date))"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button(action: onTap) {
|
||||
ZStack {
|
||||
@@ -330,6 +359,7 @@ struct DayCell: View {
|
||||
.frame(width: 36, height: 36)
|
||||
}
|
||||
}
|
||||
.accessibilityIdentifier(accessibilityId)
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isPast)
|
||||
.frame(height: 40)
|
||||
|
||||
@@ -9,6 +9,7 @@ import SwiftUI
|
||||
|
||||
struct PlanningModeStep: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.isDemoMode) private var isDemoMode
|
||||
@Binding var selection: PlanningMode?
|
||||
|
||||
var body: some View {
|
||||
@@ -35,6 +36,15 @@ struct PlanningModeStep: View {
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.large)
|
||||
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
|
||||
}
|
||||
.onAppear {
|
||||
if isDemoMode && selection == nil {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + DemoConfig.selectionDelay) {
|
||||
withAnimation(.easeInOut(duration: 0.3)) {
|
||||
selection = DemoConfig.demoPlanningMode
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +90,7 @@ private struct WizardModeCard: View {
|
||||
.stroke(isSelected ? Theme.warmOrange : Theme.textMuted(colorScheme).opacity(0.3), lineWidth: isSelected ? 2 : 1)
|
||||
)
|
||||
}
|
||||
.accessibilityIdentifier("wizard.planningMode.\(mode.rawValue)")
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +84,7 @@ struct ReviewStep: View {
|
||||
.foregroundStyle(.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large))
|
||||
}
|
||||
.accessibilityIdentifier("wizard.planTripButton")
|
||||
.disabled(!canPlanTrip || isPlanning)
|
||||
}
|
||||
.padding(Theme.Spacing.lg)
|
||||
|
||||
@@ -9,10 +9,12 @@ import SwiftUI
|
||||
|
||||
struct SportsStep: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.isDemoMode) private var isDemoMode
|
||||
@Binding var selectedSports: Set<Sport>
|
||||
let sportAvailability: [Sport: Bool]
|
||||
let isLoading: Bool
|
||||
let canSelectSport: (Sport) -> Bool
|
||||
@State private var hasAppliedDemoSelection = false
|
||||
|
||||
private let columns = [
|
||||
GridItem(.flexible()),
|
||||
@@ -54,6 +56,16 @@ struct SportsStep: View {
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.large)
|
||||
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
|
||||
}
|
||||
.onAppear {
|
||||
if isDemoMode && !hasAppliedDemoSelection && selectedSports.isEmpty {
|
||||
hasAppliedDemoSelection = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + DemoConfig.selectionDelay) {
|
||||
withAnimation(.easeInOut(duration: 0.3)) {
|
||||
_ = selectedSports.insert(DemoConfig.demoSport)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func toggleSport(_ sport: Sport) {
|
||||
@@ -100,6 +112,7 @@ private struct SportCard: View {
|
||||
.stroke(borderColor, lineWidth: isSelected ? 2 : 1)
|
||||
)
|
||||
}
|
||||
.accessibilityIdentifier("wizard.sports.\(sport.rawValue.lowercased())")
|
||||
.buttonStyle(.plain)
|
||||
.opacity(isAvailable ? 1.0 : 0.5)
|
||||
.disabled(!isAvailable)
|
||||
|
||||
@@ -27,7 +27,8 @@ struct TripWizardView: View {
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
GeometryReader { geometry in
|
||||
ScrollView(.vertical) {
|
||||
VStack(spacing: Theme.Spacing.lg) {
|
||||
// Step 1: Planning Mode (always visible)
|
||||
PlanningModeStep(selection: $viewModel.planningMode)
|
||||
@@ -131,7 +132,9 @@ struct TripWizardView: View {
|
||||
}
|
||||
}
|
||||
.padding(Theme.Spacing.md)
|
||||
.frame(width: geometry.size.width)
|
||||
.animation(.easeInOut(duration: 0.2), value: viewModel.areStepsVisible)
|
||||
}
|
||||
}
|
||||
.themedBackground()
|
||||
.navigationTitle("Plan a Trip")
|
||||
|
||||
Reference in New Issue
Block a user