// // OnboardingPaywallView.swift // SportsTime // // First-launch upsell with feature pages. // import SwiftUI import StoreKit // MARK: - Background Components /// Route lines background for Unlimited Trips page struct TripRoutesBackground: View { let color: Color @State private var animate = false @State private var pulsePhase: CGFloat = 0 var body: some View { ZStack { // Main route canvas Canvas { context, size in // City points scattered across the view let points: [CGPoint] = [ CGPoint(x: size.width * 0.12, y: size.height * 0.18), CGPoint(x: size.width * 0.35, y: size.height * 0.32), CGPoint(x: size.width * 0.58, y: size.height * 0.12), CGPoint(x: size.width * 0.78, y: size.height * 0.38), CGPoint(x: size.width * 0.22, y: size.height * 0.52), CGPoint(x: size.width * 0.62, y: size.height * 0.62), CGPoint(x: size.width * 0.88, y: size.height * 0.22), CGPoint(x: size.width * 0.42, y: size.height * 0.78), CGPoint(x: size.width * 0.82, y: size.height * 0.68), CGPoint(x: size.width * 0.08, y: size.height * 0.72), CGPoint(x: size.width * 0.5, y: size.height * 0.42), CGPoint(x: size.width * 0.92, y: size.height * 0.52), ] // Draw dotted route lines connecting points let routePairs: [(Int, Int)] = [ (0, 1), (1, 3), (3, 6), (2, 6), (1, 4), (4, 5), (5, 8), (4, 9), (5, 7), (7, 9), (2, 3), (1, 10), (10, 5), (3, 11), (8, 11), (0, 9) ] for (start, end) in routePairs { var path = Path() path.move(to: points[start]) path.addLine(to: points[end]) context.stroke( path, with: .color(color.opacity(0.22)), style: StrokeStyle(lineWidth: 2.5, dash: [8, 5]) ) } // Draw city dots with varying sizes for (index, point) in points.enumerated() { let isMainCity = index % 3 == 0 let dotSize: CGFloat = isMainCity ? 10 : 6 let ringSize: CGFloat = isMainCity ? 18 : 12 // Glow effect for main cities if isMainCity { let glowPath = Path(ellipseIn: CGRect( x: point.x - 14, y: point.y - 14, width: 28, height: 28 )) context.fill(glowPath, with: .color(color.opacity(0.08))) } // Outer ring let ringPath = Path(ellipseIn: CGRect( x: point.x - ringSize / 2, y: point.y - ringSize / 2, width: ringSize, height: ringSize )) context.stroke(ringPath, with: .color(color.opacity(0.18)), lineWidth: 1.5) // Inner dot let dotPath = Path(ellipseIn: CGRect( x: point.x - dotSize / 2, y: point.y - dotSize / 2, width: dotSize, height: dotSize )) context.fill(dotPath, with: .color(color.opacity(isMainCity ? 0.35 : 0.25))) } } // Animated car/plane icon traveling along a route TravelingIcon(color: color, animate: animate) } .accessibilityHidden(true) .onAppear { guard !Theme.Animation.prefersReducedMotion else { return } Theme.Animation.withMotion(.easeInOut(duration: 2).repeatForever(autoreverses: true)) { animate = true } } } } /// Small animated icon that travels across the background private struct TravelingIcon: View { let color: Color let animate: Bool @State private var position: CGFloat = 0 var body: some View { GeometryReader { geo in Image(systemName: "car.fill") .font(.system(size: 14)) .foregroundStyle(color.opacity(0.3)) .accessibilityHidden(true) .position( x: geo.size.width * (0.15 + position * 0.7), y: geo.size.height * (0.2 + sin(position * .pi * 2) * 0.15) ) } .onAppear { guard !Theme.Animation.prefersReducedMotion else { return } Theme.Animation.withMotion(.linear(duration: 8).repeatForever(autoreverses: false)) { position = 1 } } } } /// Floating documents background for Export & Share page struct DocumentsBackground: View { let color: Color @State private var animate = false var body: some View { ZStack { // Connecting share lines in background Canvas { context, size in let centerX = size.width * 0.5 let centerY = size.height * 0.5 // Draw radiating lines from center (like sharing to multiple destinations) let endpoints: [CGPoint] = [ CGPoint(x: size.width * 0.1, y: size.height * 0.2), CGPoint(x: size.width * 0.9, y: size.height * 0.15), CGPoint(x: size.width * 0.05, y: size.height * 0.6), CGPoint(x: size.width * 0.95, y: size.height * 0.55), CGPoint(x: size.width * 0.15, y: size.height * 0.85), CGPoint(x: size.width * 0.85, y: size.height * 0.9), ] for endpoint in endpoints { var path = Path() path.move(to: CGPoint(x: centerX, y: centerY)) path.addLine(to: endpoint) context.stroke( path, with: .color(color.opacity(0.08)), style: StrokeStyle(lineWidth: 1.5, dash: [4, 6]) ) // Small circle at endpoint let dotPath = Path(ellipseIn: CGRect( x: endpoint.x - 4, y: endpoint.y - 4, width: 8, height: 8 )) context.fill(dotPath, with: .color(color.opacity(0.12))) } } // Scattered document icons with enhanced visibility ForEach(0..<12, id: \.self) { index in documentIcon(index: index) } .accessibilityHidden(true) // Central PDF badge GeometryReader { geo in ZStack { RoundedRectangle(cornerRadius: 6) .fill(color.opacity(0.08)) .frame(width: 50, height: 60) VStack(spacing: 2) { Image(systemName: "doc.text.fill") .font(.system(size: 24)) Text("PDF") .font(.system(size: 10, weight: .bold)) } .foregroundStyle(color.opacity(0.2)) } .position(x: geo.size.width * 0.5, y: geo.size.height * 0.45) .scaleEffect(animate ? 1.05 : 0.95) } } .accessibilityHidden(true) .onAppear { guard !Theme.Animation.prefersReducedMotion else { return } Theme.Animation.withMotion(.easeInOut(duration: 2.5).repeatForever(autoreverses: true)) { animate = true } } } @ViewBuilder private func documentIcon(index: Int) -> some View { let positions: [(x: CGFloat, y: CGFloat, rotation: Double, scale: CGFloat)] = [ (0.08, 0.12, -18, 0.85), (0.88, 0.18, 14, 0.75), (0.18, 0.38, 10, 0.65), (0.92, 0.45, -12, 0.9), (0.12, 0.72, 22, 0.75), (0.82, 0.78, -10, 0.85), (0.48, 0.08, 6, 0.7), (0.58, 0.88, -14, 0.8), (0.05, 0.55, 8, 0.6), (0.95, 0.32, -6, 0.7), (0.35, 0.92, 16, 0.65), (0.72, 0.05, -20, 0.75), ] let icons = ["doc.fill", "doc.text.fill", "square.and.arrow.up", "doc.richtext.fill", "doc.fill", "square.and.arrow.up.fill", "doc.text.fill", "doc.fill", "square.and.arrow.up", "doc.richtext.fill", "doc.fill", "square.and.arrow.up.fill"] let pos = positions[index] GeometryReader { geo in Image(systemName: icons[index]) .font(.system(size: 32 * pos.scale)) .foregroundStyle(color.opacity(0.18)) .rotationEffect(.degrees(pos.rotation)) .position(x: geo.size.width * pos.x, y: geo.size.height * pos.y) .offset(y: animate ? -8 : 8) .animation( .easeInOut(duration: 2.2 + Double(index) * 0.25) .repeatForever(autoreverses: true) .delay(Double(index) * 0.15), value: animate ) } } } /// Stadium map background for Track Your Journey page struct StadiumMapBackground: View { let color: Color @State private var animate = false @State private var checkmarkScale: CGFloat = 0 var body: some View { ZStack { // Map grid canvas (decorative) Canvas { context, size in // Draw subtle grid lines like a map let gridSpacing: CGFloat = 35 // Horizontal lines var y: CGFloat = 0 while y < size.height { var path = Path() path.move(to: CGPoint(x: 0, y: y)) path.addLine(to: CGPoint(x: size.width, y: y)) context.stroke(path, with: .color(color.opacity(0.06)), lineWidth: 0.5) y += gridSpacing } // Vertical lines var x: CGFloat = 0 while x < size.width { var path = Path() path.move(to: CGPoint(x: x, y: 0)) path.addLine(to: CGPoint(x: x, y: size.height)) context.stroke(path, with: .color(color.opacity(0.06)), lineWidth: 0.5) x += gridSpacing } // Stadium pin markers - more of them now let pinPositions: [(CGPoint, Bool)] = [ (CGPoint(x: size.width * 0.15, y: size.height * 0.18), true), (CGPoint(x: size.width * 0.72, y: size.height * 0.15), true), (CGPoint(x: size.width * 0.38, y: size.height * 0.35), false), (CGPoint(x: size.width * 0.88, y: size.height * 0.32), true), (CGPoint(x: size.width * 0.12, y: size.height * 0.55), false), (CGPoint(x: size.width * 0.55, y: size.height * 0.48), true), (CGPoint(x: size.width * 0.82, y: size.height * 0.58), false), (CGPoint(x: size.width * 0.28, y: size.height * 0.72), true), (CGPoint(x: size.width * 0.65, y: size.height * 0.75), false), (CGPoint(x: size.width * 0.08, y: size.height * 0.85), false), (CGPoint(x: size.width * 0.92, y: size.height * 0.78), true), (CGPoint(x: size.width * 0.48, y: size.height * 0.88), false), ] for (pos, isVisited) in pinPositions { // Pin drop shadow let shadowPath = Path(ellipseIn: CGRect(x: pos.x - 7, y: pos.y + 12, width: 14, height: 5)) context.fill(shadowPath, with: .color(.black.opacity(0.1))) // Pin body (teardrop shape) var pinPath = Path() pinPath.move(to: CGPoint(x: pos.x, y: pos.y + 14)) pinPath.addQuadCurve( to: CGPoint(x: pos.x - 10, y: pos.y - 5), control: CGPoint(x: pos.x - 12, y: pos.y + 7) ) pinPath.addArc( center: CGPoint(x: pos.x, y: pos.y - 5), radius: 10, startAngle: .degrees(180), endAngle: .degrees(0), clockwise: false ) pinPath.addQuadCurve( to: CGPoint(x: pos.x, y: pos.y + 14), control: CGPoint(x: pos.x + 12, y: pos.y + 7) ) // Visited pins are filled, unvisited are outlined if isVisited { context.fill(pinPath, with: .color(color.opacity(0.28))) // Checkmark inside visited pins let checkCenter = CGPoint(x: pos.x, y: pos.y - 5) var checkPath = Path() checkPath.move(to: CGPoint(x: checkCenter.x - 4, y: checkCenter.y)) checkPath.addLine(to: CGPoint(x: checkCenter.x - 1, y: checkCenter.y + 3)) checkPath.addLine(to: CGPoint(x: checkCenter.x + 4, y: checkCenter.y - 3)) context.stroke(checkPath, with: .color(.white.opacity(0.6)), lineWidth: 2) } else { context.stroke(pinPath, with: .color(color.opacity(0.2)), lineWidth: 2) // Empty dot for unvisited let dotPath = Path(ellipseIn: CGRect(x: pos.x - 4, y: pos.y - 9, width: 8, height: 8)) context.stroke(dotPath, with: .color(color.opacity(0.15)), lineWidth: 1.5) } } } // Floating achievement badges ForEach(0..<3, id: \.self) { index in achievementBadge(index: index) } // Progress counter badge GeometryReader { geo in HStack(spacing: 4) { Text("6") .font(.title3.bold()) Text("/") .font(.subheadline) Text("12") .font(.subheadline.weight(.medium)) } .foregroundStyle(color.opacity(0.25)) .padding(.horizontal, 12) .padding(.vertical, 6) .background(color.opacity(0.06), in: Capsule()) .position(x: geo.size.width * 0.5, y: geo.size.height * 0.92) } } .accessibilityHidden(true) .onAppear { guard !Theme.Animation.prefersReducedMotion else { return } Theme.Animation.withMotion(.easeInOut(duration: 2.5).repeatForever(autoreverses: true)) { animate = true } Theme.Animation.withMotion(.spring(response: 0.6, dampingFraction: 0.6).delay(0.3)) { checkmarkScale = 1 } } } @ViewBuilder private func achievementBadge(index: Int) -> some View { let badges: [(x: CGFloat, y: CGFloat, icon: String, rotation: Double)] = [ (0.12, 0.08, "trophy.fill", -12), (0.88, 0.1, "star.fill", 8), (0.9, 0.88, "medal.fill", -6), ] let badge = badges[index] GeometryReader { geo in ZStack { Circle() .fill(color.opacity(0.1)) .frame(width: 36, height: 36) Image(systemName: badge.icon) .font(.system(size: 16)) .foregroundStyle(color.opacity(0.25)) } .rotationEffect(.degrees(badge.rotation)) .position(x: geo.size.width * badge.x, y: geo.size.height * badge.y) .scaleEffect(animate ? 1.08 : 0.95) .animation( .easeInOut(duration: 2 + Double(index) * 0.3) .repeatForever(autoreverses: true) .delay(Double(index) * 0.2), value: animate ) } } } struct OnboardingPaywallView: View { @Environment(\.colorScheme) private var colorScheme @Binding var isPresented: Bool @State private var currentPage = 0 private let pages: [(icon: String, title: String, subtitle: String, bullets: [String], color: Color)] = [ ( "suitcase.fill", "Unlimited Trips", "Your ultimate sports road trip companion", [ "Plan unlimited multi-city adventures", "Smart routing finds the best game combinations", "Save and revisit your favorite itineraries", "Never miss a rivalry game or playoff matchup" ], Theme.warmOrange ), ( "doc.fill", "Export & Share", "Beautiful itineraries at your fingertips", [ "Generate stunning PDF trip guides", "Share plans with friends and family", "Includes maps, schedules, and travel times", "Perfect for group trips and coordination" ], Theme.routeGold ), ( "trophy.fill", "Track Your Journey", "Build your sports legacy", [ "Log every stadium you visit", "Earn badges for milestones and achievements", "Complete your bucket list across all leagues", "Share your progress with fellow fans" ], .green ) ] var body: some View { VStack(spacing: 0) { // Page content TabView(selection: $currentPage) { ForEach(0.. some View { let page = pages[index] return ZStack { // Themed background based on page backgroundForPage(index: index, color: page.color) VStack(spacing: Theme.Spacing.lg) { Spacer() ZStack { Circle() .fill(page.color.opacity(0.15)) .frame(width: 100, height: 100) Image(systemName: page.icon) .font(.largeTitle) .foregroundStyle(page.color) .accessibilityHidden(true) } VStack(spacing: Theme.Spacing.sm) { Text(page.title) .font(.title.bold()) .foregroundStyle(Theme.textPrimary(colorScheme)) Text(page.subtitle) .font(.subheadline) .foregroundStyle(Theme.textSecondary(colorScheme)) } // Feature bullets VStack(alignment: .leading, spacing: Theme.Spacing.sm) { ForEach(page.bullets, id: \.self) { bullet in HStack(alignment: .top, spacing: Theme.Spacing.sm) { Image(systemName: "checkmark.circle.fill") .foregroundStyle(page.color) .font(.body) .accessibilityHidden(true) Text(bullet) .font(.body) .foregroundStyle(Theme.textPrimary(colorScheme)) Spacer() } } } .padding(.horizontal, Theme.Spacing.xl) .padding(.top, Theme.Spacing.md) Spacer() Spacer() } } } @ViewBuilder private func backgroundForPage(index: Int, color: Color) -> some View { switch index { case 0: // Unlimited Trips - route map with dotted paths TripRoutesBackground(color: color) case 1: // Export & Share - floating documents DocumentsBackground(color: color) case 2: // Track Your Journey - stadium map with pins StadiumMapBackground(color: color) default: EmptyView() } } // MARK: - Pricing Page private var pricingPage: some View { PaywallView(source: "onboarding") .storeButton(.hidden, for: .cancellation) } // MARK: - Bottom Buttons private var bottomButtons: some View { VStack(spacing: Theme.Spacing.md) { if currentPage < pages.count { // Next button Button { withAnimation { currentPage += 1 } } label: { Text("Next") .fontWeight(.semibold) .frame(maxWidth: .infinity) .padding(Theme.Spacing.md) .background(Theme.warmOrange) .foregroundStyle(.white) .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) } .accessibilityHint("Page \(currentPage + 1) of \(pages.count + 1)") } // Continue free (always visible) Button { markOnboardingSeen() AnalyticsManager.shared.track(.onboardingPaywallDismissed) isPresented = false } label: { Text("Continue with Free") .font(.subheadline) .foregroundStyle(Theme.textMuted(colorScheme)) } .accessibilityHint("Skip and continue with free version") } } private func markOnboardingSeen() { UserDefaults.standard.set(true, forKey: "hasSeenOnboardingPaywall") } } #Preview { OnboardingPaywallView(isPresented: .constant(true)) }