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>
This commit is contained in:
Trey t
2026-02-13 12:07:35 -06:00
parent 67965cbac6
commit 5f5b137e64
655 changed files with 4008 additions and 63 deletions

View File

@@ -511,6 +511,45 @@ final class ItineraryTableViewController: UITableViewController {
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
//
// These methods handle the start, update, and end of drag operations,

View File

@@ -90,6 +90,10 @@ struct ItineraryTableViewWrapper<HeaderContent: View>: UIViewControllerRepresent
travelSegmentIndices: travelSegmentIndices
)
#if DEBUG
controller.scrollToBottomAnimated(delay: 5.0)
#endif
return controller
}

View File

@@ -254,32 +254,49 @@ struct TripDetailView: View {
.ignoresSafeArea(edges: .bottom)
} else {
// Non-editable scroll view for unsaved trips
ScrollView {
VStack(spacing: 0) {
heroMapSection
.frame(height: 280)
ScrollViewReader { proxy in
ScrollView {
VStack(spacing: 0) {
heroMapSection
.frame(height: 280)
VStack(spacing: Theme.Spacing.lg) {
tripHeader
.padding(.top, Theme.Spacing.lg)
VStack(spacing: Theme.Spacing.lg) {
tripHeader
.padding(.top, Theme.Spacing.lg)
statsRow
statsRow
if let score = trip.score {
scoreCard(score)
if let score = trip.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
draggedTravelId = nil
draggedItem = nil
dropTargetId = nil
return true
#if DEBUG
.onAppear {
if UserDefaults.standard.bool(forKey: "marketingVideoMode") {
DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) {
withAnimation(.easeInOut(duration: 6.0)) {
proxy.scrollTo("tripDetailBottom", anchor: .bottom)
}
}
}
}
#endif
}
}
}

View File

@@ -231,8 +231,9 @@ struct TripOptionsView: View {
}
var body: some View {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(spacing: 16) {
VStack(spacing: 16) {
// Hero header
VStack(spacing: 8) {
Image(systemName: "point.topright.arrow.triangle.backward.to.point.bottomleft.scurvepath.fill")
@@ -291,8 +292,27 @@ struct TripOptionsView: View {
}
}
.padding(.bottom, Theme.Spacing.xxl)
Color.clear.frame(height: 1).id("tripOptionsBottom")
}
.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) {
if let trip = selectedTrip {
TripDetailView(trip: trip)

View File

@@ -28,6 +28,7 @@ struct TripWizardView: View {
var body: some View {
NavigationStack {
GeometryReader { geometry in
ScrollViewReader { proxy in
ScrollView(.vertical) {
VStack(spacing: Theme.Spacing.lg) {
// Step 1: Planning Mode (always visible)
@@ -130,11 +131,23 @@ struct TripWizardView: View {
}
.transition(.opacity)
}
Color.clear
.frame(height: 1)
.id("wizardBottom")
}
.padding(Theme.Spacing.md)
.frame(width: geometry.size.width)
.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()
.navigationTitle("Plan a Trip")
@@ -335,6 +348,72 @@ struct TripWizardView: View {
}
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