feat: add marketing video mode and Remotion marketing video project
Add debug-only Marketing Video Mode toggle that enables hands-free screen recording across the app: auto-scrolling Featured Trips carousel, auto-filling trip wizard, smooth trip detail scrolling via CADisplayLink, and trip options auto-sort with scroll. Add Remotion marketing video project with 6 scene compositions using image sequences extracted from screen recordings, varied phone entrance animations, and deduped frames for smooth playback. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@@ -12,6 +12,7 @@ struct AdaptiveHomeContent: View {
|
|||||||
@Binding var showNewTrip: Bool
|
@Binding var showNewTrip: Bool
|
||||||
@Binding var selectedTab: Int
|
@Binding var selectedTab: Int
|
||||||
@Binding var selectedSuggestedTrip: SuggestedTrip?
|
@Binding var selectedSuggestedTrip: SuggestedTrip?
|
||||||
|
@Binding var marketingAutoScroll: Bool
|
||||||
|
|
||||||
let savedTrips: [SavedTrip]
|
let savedTrips: [SavedTrip]
|
||||||
let suggestedTripsGenerator: SuggestedTripsGenerator
|
let suggestedTripsGenerator: SuggestedTripsGenerator
|
||||||
@@ -24,6 +25,7 @@ struct AdaptiveHomeContent: View {
|
|||||||
showNewTrip: $showNewTrip,
|
showNewTrip: $showNewTrip,
|
||||||
selectedTab: $selectedTab,
|
selectedTab: $selectedTab,
|
||||||
selectedSuggestedTrip: $selectedSuggestedTrip,
|
selectedSuggestedTrip: $selectedSuggestedTrip,
|
||||||
|
marketingAutoScroll: $marketingAutoScroll,
|
||||||
savedTrips: savedTrips,
|
savedTrips: savedTrips,
|
||||||
suggestedTripsGenerator: suggestedTripsGenerator,
|
suggestedTripsGenerator: suggestedTripsGenerator,
|
||||||
displayedTips: displayedTips
|
displayedTips: displayedTips
|
||||||
@@ -34,6 +36,7 @@ struct AdaptiveHomeContent: View {
|
|||||||
showNewTrip: $showNewTrip,
|
showNewTrip: $showNewTrip,
|
||||||
selectedTab: $selectedTab,
|
selectedTab: $selectedTab,
|
||||||
selectedSuggestedTrip: $selectedSuggestedTrip,
|
selectedSuggestedTrip: $selectedSuggestedTrip,
|
||||||
|
marketingAutoScroll: $marketingAutoScroll,
|
||||||
savedTrips: savedTrips,
|
savedTrips: savedTrips,
|
||||||
suggestedTripsGenerator: suggestedTripsGenerator,
|
suggestedTripsGenerator: suggestedTripsGenerator,
|
||||||
displayedTips: displayedTips
|
displayedTips: displayedTips
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ struct HomeView: View {
|
|||||||
@State private var selectedSuggestedTrip: SuggestedTrip?
|
@State private var selectedSuggestedTrip: SuggestedTrip?
|
||||||
@State private var displayedTips: [PlanningTip] = []
|
@State private var displayedTips: [PlanningTip] = []
|
||||||
@State private var showProPaywall = false
|
@State private var showProPaywall = false
|
||||||
|
#if DEBUG
|
||||||
|
@State private var marketingAutoScroll = false
|
||||||
|
#endif
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
TabView(selection: $selectedTab) {
|
TabView(selection: $selectedTab) {
|
||||||
@@ -27,6 +30,7 @@ struct HomeView: View {
|
|||||||
showNewTrip: $showNewTrip,
|
showNewTrip: $showNewTrip,
|
||||||
selectedTab: $selectedTab,
|
selectedTab: $selectedTab,
|
||||||
selectedSuggestedTrip: $selectedSuggestedTrip,
|
selectedSuggestedTrip: $selectedSuggestedTrip,
|
||||||
|
marketingAutoScroll: marketingAutoScrollBinding,
|
||||||
savedTrips: savedTrips,
|
savedTrips: savedTrips,
|
||||||
suggestedTripsGenerator: suggestedTripsGenerator,
|
suggestedTripsGenerator: suggestedTripsGenerator,
|
||||||
displayedTips: displayedTips
|
displayedTips: displayedTips
|
||||||
@@ -98,6 +102,13 @@ struct HomeView: View {
|
|||||||
let oldName = oldTab < tabNames.count ? tabNames[oldTab] : nil
|
let oldName = oldTab < tabNames.count ? tabNames[oldTab] : nil
|
||||||
AnalyticsManager.shared.track(.tabSwitched(tab: newName, previousTab: oldName))
|
AnalyticsManager.shared.track(.tabSwitched(tab: newName, previousTab: oldName))
|
||||||
AnalyticsManager.shared.trackScreen(newName)
|
AnalyticsManager.shared.trackScreen(newName)
|
||||||
|
#if DEBUG
|
||||||
|
if newTab == 0 && UserDefaults.standard.bool(forKey: "marketingVideoMode") {
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
|
||||||
|
marketingAutoScroll = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showNewTrip) {
|
.sheet(isPresented: $showNewTrip) {
|
||||||
TripWizardView()
|
TripWizardView()
|
||||||
@@ -131,6 +142,14 @@ struct HomeView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var marketingAutoScrollBinding: Binding<Bool> {
|
||||||
|
#if DEBUG
|
||||||
|
return $marketingAutoScroll
|
||||||
|
#else
|
||||||
|
return .constant(false)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Hero Card
|
// MARK: - Hero Card
|
||||||
|
|
||||||
private var heroCard: some View {
|
private var heroCard: some View {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ struct HomeContent_Classic: View {
|
|||||||
@Binding var showNewTrip: Bool
|
@Binding var showNewTrip: Bool
|
||||||
@Binding var selectedTab: Int
|
@Binding var selectedTab: Int
|
||||||
@Binding var selectedSuggestedTrip: SuggestedTrip?
|
@Binding var selectedSuggestedTrip: SuggestedTrip?
|
||||||
|
@Binding var marketingAutoScroll: Bool
|
||||||
|
|
||||||
let savedTrips: [SavedTrip]
|
let savedTrips: [SavedTrip]
|
||||||
let suggestedTripsGenerator: SuggestedTripsGenerator
|
let suggestedTripsGenerator: SuggestedTripsGenerator
|
||||||
@@ -124,36 +125,50 @@ struct HomeContent_Classic: View {
|
|||||||
.padding(.horizontal, Theme.Spacing.md)
|
.padding(.horizontal, Theme.Spacing.md)
|
||||||
|
|
||||||
// Horizontal carousel grouped by region
|
// Horizontal carousel grouped by region
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
ScrollViewReader { proxy in
|
||||||
HStack(spacing: Theme.Spacing.lg) {
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
ForEach(suggestedTripsGenerator.tripsByRegion, id: \.region) { regionGroup in
|
HStack(spacing: Theme.Spacing.lg) {
|
||||||
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
ForEach(suggestedTripsGenerator.tripsByRegion, id: \.region) { regionGroup in
|
||||||
// Region header
|
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
||||||
HStack(spacing: Theme.Spacing.xs) {
|
// Region header
|
||||||
Image(systemName: regionGroup.region.iconName)
|
HStack(spacing: Theme.Spacing.xs) {
|
||||||
.font(.caption)
|
Image(systemName: regionGroup.region.iconName)
|
||||||
.accessibilityHidden(true)
|
.font(.caption)
|
||||||
Text(regionGroup.region.shortName)
|
.accessibilityHidden(true)
|
||||||
.font(.subheadline)
|
Text(regionGroup.region.shortName)
|
||||||
}
|
.font(.subheadline)
|
||||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
}
|
||||||
|
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||||
|
|
||||||
// Trip cards for this region
|
// Trip cards for this region
|
||||||
HStack(spacing: Theme.Spacing.md) {
|
HStack(spacing: Theme.Spacing.md) {
|
||||||
ForEach(regionGroup.trips) { suggestedTrip in
|
ForEach(regionGroup.trips) { suggestedTrip in
|
||||||
Button {
|
Button {
|
||||||
selectedSuggestedTrip = suggestedTrip
|
selectedSuggestedTrip = suggestedTrip
|
||||||
} label: {
|
} label: {
|
||||||
SuggestedTripCard(suggestedTrip: suggestedTrip)
|
SuggestedTripCard(suggestedTrip: suggestedTrip)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.id(regionGroup.region)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.contentMargins(.horizontal, Theme.Spacing.md, for: .scrollContent)
|
||||||
|
.onChange(of: marketingAutoScroll) { _, shouldScroll in
|
||||||
|
if shouldScroll,
|
||||||
|
let lastRegion = suggestedTripsGenerator.tripsByRegion.last?.region {
|
||||||
|
withAnimation(.easeInOut(duration: 3.0)) {
|
||||||
|
proxy.scrollTo(lastRegion, anchor: .trailing)
|
||||||
|
}
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 3.5) {
|
||||||
|
marketingAutoScroll = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.contentMargins(.horizontal, Theme.Spacing.md, for: .scrollContent)
|
|
||||||
}
|
}
|
||||||
} else if let error = suggestedTripsGenerator.error {
|
} else if let error = suggestedTripsGenerator.error {
|
||||||
// Error state
|
// Error state
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ struct HomeContent_ClassicAnimated: View {
|
|||||||
@Binding var showNewTrip: Bool
|
@Binding var showNewTrip: Bool
|
||||||
@Binding var selectedTab: Int
|
@Binding var selectedTab: Int
|
||||||
@Binding var selectedSuggestedTrip: SuggestedTrip?
|
@Binding var selectedSuggestedTrip: SuggestedTrip?
|
||||||
|
@Binding var marketingAutoScroll: Bool
|
||||||
|
|
||||||
let savedTrips: [SavedTrip]
|
let savedTrips: [SavedTrip]
|
||||||
let suggestedTripsGenerator: SuggestedTripsGenerator
|
let suggestedTripsGenerator: SuggestedTripsGenerator
|
||||||
@@ -123,36 +124,50 @@ struct HomeContent_ClassicAnimated: View {
|
|||||||
.padding(.horizontal, Theme.Spacing.md)
|
.padding(.horizontal, Theme.Spacing.md)
|
||||||
|
|
||||||
// Horizontal carousel grouped by region
|
// Horizontal carousel grouped by region
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
ScrollViewReader { proxy in
|
||||||
HStack(spacing: Theme.Spacing.lg) {
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
ForEach(suggestedTripsGenerator.tripsByRegion, id: \.region) { regionGroup in
|
HStack(spacing: Theme.Spacing.lg) {
|
||||||
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
ForEach(suggestedTripsGenerator.tripsByRegion, id: \.region) { regionGroup in
|
||||||
// Region header
|
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
||||||
HStack(spacing: Theme.Spacing.xs) {
|
// Region header
|
||||||
Image(systemName: regionGroup.region.iconName)
|
HStack(spacing: Theme.Spacing.xs) {
|
||||||
.font(.caption)
|
Image(systemName: regionGroup.region.iconName)
|
||||||
.accessibilityHidden(true)
|
.font(.caption)
|
||||||
Text(regionGroup.region.shortName)
|
.accessibilityHidden(true)
|
||||||
.font(.subheadline)
|
Text(regionGroup.region.shortName)
|
||||||
}
|
.font(.subheadline)
|
||||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
}
|
||||||
|
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||||
|
|
||||||
// Trip cards for this region
|
// Trip cards for this region
|
||||||
HStack(spacing: Theme.Spacing.md) {
|
HStack(spacing: Theme.Spacing.md) {
|
||||||
ForEach(regionGroup.trips) { suggestedTrip in
|
ForEach(regionGroup.trips) { suggestedTrip in
|
||||||
Button {
|
Button {
|
||||||
selectedSuggestedTrip = suggestedTrip
|
selectedSuggestedTrip = suggestedTrip
|
||||||
} label: {
|
} label: {
|
||||||
SuggestedTripCard(suggestedTrip: suggestedTrip)
|
SuggestedTripCard(suggestedTrip: suggestedTrip)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.id(regionGroup.region)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.contentMargins(.horizontal, Theme.Spacing.md, for: .scrollContent)
|
||||||
|
.onChange(of: marketingAutoScroll) { _, shouldScroll in
|
||||||
|
if shouldScroll,
|
||||||
|
let lastRegion = suggestedTripsGenerator.tripsByRegion.last?.region {
|
||||||
|
withAnimation(.easeInOut(duration: 3.0)) {
|
||||||
|
proxy.scrollTo(lastRegion, anchor: .trailing)
|
||||||
|
}
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 3.5) {
|
||||||
|
marketingAutoScroll = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.contentMargins(.horizontal, Theme.Spacing.md, for: .scrollContent)
|
|
||||||
}
|
}
|
||||||
} else if let error = suggestedTripsGenerator.error {
|
} else if let error = suggestedTripsGenerator.error {
|
||||||
// Error state
|
// Error state
|
||||||
|
|||||||
@@ -519,6 +519,13 @@ struct SettingsView: View {
|
|||||||
Label("Override Pro Status", systemImage: "star.fill")
|
Label("Override Pro Status", systemImage: "star.fill")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Toggle(isOn: Binding(
|
||||||
|
get: { UserDefaults.standard.bool(forKey: "marketingVideoMode") },
|
||||||
|
set: { UserDefaults.standard.set($0, forKey: "marketingVideoMode") }
|
||||||
|
)) {
|
||||||
|
Label("Marketing Video Mode", systemImage: "video.fill")
|
||||||
|
}
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
showOnboardingPaywall = true
|
showOnboardingPaywall = true
|
||||||
} label: {
|
} label: {
|
||||||
|
|||||||
@@ -511,6 +511,45 @@ final class ItineraryTableViewController: UITableViewController {
|
|||||||
ItineraryReorderingLogic.travelRow(in: flatItems, forDay: day)
|
ItineraryReorderingLogic.travelRow(in: flatItems, forDay: day)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Marketing Video Auto-Scroll
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
private var displayLink: CADisplayLink?
|
||||||
|
private var scrollStartTime: CFTimeInterval = 0
|
||||||
|
private var scrollDuration: CFTimeInterval = 6.0
|
||||||
|
private var scrollStartOffset: CGFloat = 0
|
||||||
|
private var scrollEndOffset: CGFloat = 0
|
||||||
|
|
||||||
|
func scrollToBottomAnimated(delay: TimeInterval = 1.5, duration: TimeInterval = 6.0) {
|
||||||
|
guard UserDefaults.standard.bool(forKey: "marketingVideoMode") else { return }
|
||||||
|
self.scrollDuration = duration
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
let maxOffset = self.tableView.contentSize.height - self.tableView.bounds.height + self.tableView.contentInset.bottom
|
||||||
|
guard maxOffset > 0 else { return }
|
||||||
|
self.scrollStartOffset = self.tableView.contentOffset.y
|
||||||
|
self.scrollEndOffset = maxOffset
|
||||||
|
self.scrollStartTime = CACurrentMediaTime()
|
||||||
|
let link = CADisplayLink(target: self, selector: #selector(self.marketingScrollTick))
|
||||||
|
link.add(to: .main, forMode: .common)
|
||||||
|
self.displayLink = link
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func marketingScrollTick() {
|
||||||
|
let elapsed = CACurrentMediaTime() - scrollStartTime
|
||||||
|
let t = min(elapsed / scrollDuration, 1.0)
|
||||||
|
// Ease-in-out curve
|
||||||
|
let eased = t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t
|
||||||
|
let newOffset = scrollStartOffset + (scrollEndOffset - scrollStartOffset) * eased
|
||||||
|
tableView.contentOffset = CGPoint(x: 0, y: newOffset)
|
||||||
|
if t >= 1.0 {
|
||||||
|
displayLink?.invalidate()
|
||||||
|
displayLink = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
// MARK: - Drag State Management
|
// MARK: - Drag State Management
|
||||||
//
|
//
|
||||||
// These methods handle the start, update, and end of drag operations,
|
// These methods handle the start, update, and end of drag operations,
|
||||||
|
|||||||
@@ -90,6 +90,10 @@ struct ItineraryTableViewWrapper<HeaderContent: View>: UIViewControllerRepresent
|
|||||||
travelSegmentIndices: travelSegmentIndices
|
travelSegmentIndices: travelSegmentIndices
|
||||||
)
|
)
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
controller.scrollToBottomAnimated(delay: 5.0)
|
||||||
|
#endif
|
||||||
|
|
||||||
return controller
|
return controller
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -254,32 +254,49 @@ struct TripDetailView: View {
|
|||||||
.ignoresSafeArea(edges: .bottom)
|
.ignoresSafeArea(edges: .bottom)
|
||||||
} else {
|
} else {
|
||||||
// Non-editable scroll view for unsaved trips
|
// Non-editable scroll view for unsaved trips
|
||||||
ScrollView {
|
ScrollViewReader { proxy in
|
||||||
VStack(spacing: 0) {
|
ScrollView {
|
||||||
heroMapSection
|
VStack(spacing: 0) {
|
||||||
.frame(height: 280)
|
heroMapSection
|
||||||
|
.frame(height: 280)
|
||||||
|
|
||||||
VStack(spacing: Theme.Spacing.lg) {
|
VStack(spacing: Theme.Spacing.lg) {
|
||||||
tripHeader
|
tripHeader
|
||||||
.padding(.top, Theme.Spacing.lg)
|
.padding(.top, Theme.Spacing.lg)
|
||||||
|
|
||||||
statsRow
|
statsRow
|
||||||
|
|
||||||
if let score = trip.score {
|
if let score = trip.score {
|
||||||
scoreCard(score)
|
scoreCard(score)
|
||||||
|
}
|
||||||
|
|
||||||
|
itinerarySection
|
||||||
}
|
}
|
||||||
|
.padding(.horizontal, Theme.Spacing.lg)
|
||||||
|
.padding(.bottom, Theme.Spacing.xxl)
|
||||||
|
|
||||||
itinerarySection
|
Color.clear
|
||||||
|
.frame(height: 1)
|
||||||
|
.id("tripDetailBottom")
|
||||||
|
}
|
||||||
|
.onDrop(of: [.text, .plainText, .utf8PlainText], isTargeted: .constant(false)) { _ in
|
||||||
|
draggedTravelId = nil
|
||||||
|
draggedItem = nil
|
||||||
|
dropTargetId = nil
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
.padding(.horizontal, Theme.Spacing.lg)
|
|
||||||
.padding(.bottom, Theme.Spacing.xxl)
|
|
||||||
}
|
}
|
||||||
.onDrop(of: [.text, .plainText, .utf8PlainText], isTargeted: .constant(false)) { _ in
|
#if DEBUG
|
||||||
draggedTravelId = nil
|
.onAppear {
|
||||||
draggedItem = nil
|
if UserDefaults.standard.bool(forKey: "marketingVideoMode") {
|
||||||
dropTargetId = nil
|
DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) {
|
||||||
return true
|
withAnimation(.easeInOut(duration: 6.0)) {
|
||||||
|
proxy.scrollTo("tripDetailBottom", anchor: .bottom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -231,8 +231,9 @@ struct TripOptionsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
ScrollViewReader { proxy in
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
// Hero header
|
// Hero header
|
||||||
VStack(spacing: 8) {
|
VStack(spacing: 8) {
|
||||||
Image(systemName: "point.topright.arrow.triangle.backward.to.point.bottomleft.scurvepath.fill")
|
Image(systemName: "point.topright.arrow.triangle.backward.to.point.bottomleft.scurvepath.fill")
|
||||||
@@ -291,8 +292,27 @@ struct TripOptionsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.bottom, Theme.Spacing.xxl)
|
.padding(.bottom, Theme.Spacing.xxl)
|
||||||
|
Color.clear.frame(height: 1).id("tripOptionsBottom")
|
||||||
}
|
}
|
||||||
.themedBackground()
|
.themedBackground()
|
||||||
|
#if DEBUG
|
||||||
|
.onAppear {
|
||||||
|
if UserDefaults.standard.bool(forKey: "marketingVideoMode") && !hasAppliedDemoSelection {
|
||||||
|
hasAppliedDemoSelection = true
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
||||||
|
Theme.Animation.withMotion(.easeInOut(duration: 0.3)) {
|
||||||
|
sortOption = .mostGames
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) {
|
||||||
|
withAnimation(.easeInOut(duration: 20.0)) {
|
||||||
|
proxy.scrollTo("tripOptionsBottom", anchor: .bottom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
} // ScrollViewReader
|
||||||
.navigationDestination(isPresented: $showTripDetail) {
|
.navigationDestination(isPresented: $showTripDetail) {
|
||||||
if let trip = selectedTrip {
|
if let trip = selectedTrip {
|
||||||
TripDetailView(trip: trip)
|
TripDetailView(trip: trip)
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ struct TripWizardView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
GeometryReader { geometry in
|
GeometryReader { geometry in
|
||||||
|
ScrollViewReader { proxy in
|
||||||
ScrollView(.vertical) {
|
ScrollView(.vertical) {
|
||||||
VStack(spacing: Theme.Spacing.lg) {
|
VStack(spacing: Theme.Spacing.lg) {
|
||||||
// Step 1: Planning Mode (always visible)
|
// Step 1: Planning Mode (always visible)
|
||||||
@@ -130,11 +131,23 @@ struct TripWizardView: View {
|
|||||||
}
|
}
|
||||||
.transition(.opacity)
|
.transition(.opacity)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Color.clear
|
||||||
|
.frame(height: 1)
|
||||||
|
.id("wizardBottom")
|
||||||
}
|
}
|
||||||
.padding(Theme.Spacing.md)
|
.padding(Theme.Spacing.md)
|
||||||
.frame(width: geometry.size.width)
|
.frame(width: geometry.size.width)
|
||||||
.animation(Theme.Animation.prefersReducedMotion ? .none : .easeInOut(duration: 0.2), value: viewModel.areStepsVisible)
|
.animation(Theme.Animation.prefersReducedMotion ? .none : .easeInOut(duration: 0.2), value: viewModel.areStepsVisible)
|
||||||
}
|
}
|
||||||
|
#if DEBUG
|
||||||
|
.onChange(of: viewModel.planningMode) { _, newMode in
|
||||||
|
if newMode == .gameFirst && UserDefaults.standard.bool(forKey: "marketingVideoMode") {
|
||||||
|
marketingAutoFill(proxy: proxy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.themedBackground()
|
.themedBackground()
|
||||||
.navigationTitle("Plan a Trip")
|
.navigationTitle("Plan a Trip")
|
||||||
@@ -335,6 +348,72 @@ struct TripWizardView: View {
|
|||||||
}
|
}
|
||||||
return cities.joined(separator: " → ")
|
return cities.joined(separator: " → ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Marketing Video Auto-Fill
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
private func marketingAutoFill(proxy: ScrollViewProxy) {
|
||||||
|
// Pre-fetch data off the main thread, then run a clean sequential fill
|
||||||
|
Task {
|
||||||
|
let astros = AppDataProvider.shared.teams.first { $0.fullName.contains("Astros") }
|
||||||
|
let allGames = try? await AppDataProvider.shared.allGames(for: [.mlb])
|
||||||
|
let astrosGames = (allGames ?? []).filter {
|
||||||
|
$0.homeTeamId == astros?.id || $0.awayTeamId == astros?.id
|
||||||
|
}
|
||||||
|
let pickedGames = Array(astrosGames.shuffled().prefix(3))
|
||||||
|
let pickedIds = Set(pickedGames.map { $0.id })
|
||||||
|
|
||||||
|
// Sequential fill with generous pauses — no competing animations
|
||||||
|
await MainActor.run {
|
||||||
|
// Step 1: Select MLB
|
||||||
|
viewModel.gamePickerSports = [.mlb]
|
||||||
|
}
|
||||||
|
try? await Task.sleep(for: .seconds(1.5))
|
||||||
|
|
||||||
|
await MainActor.run {
|
||||||
|
// Step 2: Select Astros
|
||||||
|
if let astros {
|
||||||
|
viewModel.gamePickerTeamIds = [astros.id]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try? await Task.sleep(for: .seconds(1.5))
|
||||||
|
|
||||||
|
await MainActor.run {
|
||||||
|
// Step 3: Pick games + set date range
|
||||||
|
if !pickedIds.isEmpty {
|
||||||
|
viewModel.selectedGameIds = pickedIds
|
||||||
|
if let earliest = pickedGames.map({ $0.dateTime }).min(),
|
||||||
|
let latest = pickedGames.map({ $0.dateTime }).max() {
|
||||||
|
viewModel.startDate = Calendar.current.date(byAdding: .day, value: -1, to: earliest) ?? earliest
|
||||||
|
viewModel.endDate = Calendar.current.date(byAdding: .day, value: 1, to: latest) ?? latest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try? await Task.sleep(for: .seconds(1.5))
|
||||||
|
|
||||||
|
await MainActor.run {
|
||||||
|
// Step 4: Balanced route
|
||||||
|
viewModel.routePreference = .balanced
|
||||||
|
viewModel.hasSetRoutePreference = true
|
||||||
|
}
|
||||||
|
try? await Task.sleep(for: .seconds(1.5))
|
||||||
|
|
||||||
|
await MainActor.run {
|
||||||
|
// Step 5: Allow repeat cities
|
||||||
|
viewModel.allowRepeatCities = true
|
||||||
|
viewModel.hasSetRepeatCities = true
|
||||||
|
}
|
||||||
|
try? await Task.sleep(for: .seconds(0.5))
|
||||||
|
|
||||||
|
// Single smooth scroll to bottom after everything is laid out
|
||||||
|
await MainActor.run {
|
||||||
|
withAnimation(.easeInOut(duration: 5.0)) {
|
||||||
|
proxy.scrollTo("wizardBottom", anchor: .bottom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Preview
|
// MARK: - Preview
|
||||||
|
|||||||
4
marketing-videos/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
out/
|
||||||
|
.DS_Store
|
||||||
2838
marketing-videos/package-lock.json
generated
Normal file
21
marketing-videos/package.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "sportstime-marketing-videos",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"start": "npx remotion studio",
|
||||||
|
"render": "npx remotion render app-store-preview out/AppStorePreview.mp4",
|
||||||
|
"build": "npx remotion render app-store-preview out/AppStorePreview.mp4"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@remotion/cli": "^4.0.421",
|
||||||
|
"@remotion/transitions": "^4.0.421",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"remotion": "^4.0.421"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.3",
|
||||||
|
"typescript": "^5.5.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
marketing-videos/public/appIcon.png
Normal file
|
After Width: | Height: | Size: 217 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 39 KiB |