feat(paywall): enhance onboarding with rich backgrounds and intro pricing

- Add themed backgrounds for each onboarding feature page:
  - Unlimited Trips: animated route map with dotted paths and traveling car
  - Export & Share: floating documents with radiating share lines
  - Track Your Journey: stadium map with pins and achievement badges
- Add sports-themed pricing page background with random glow effects
- Display introductory offer pricing in subscription rows
- Add feature bullets to each onboarding page for better value prop
- Add crown icon header and feature pills to pricing page
- Add debug button in Settings to preview onboarding flow
- Create StoreKit configuration file for testing IAP

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-01-13 20:07:47 -06:00
parent 76a6958f5e
commit bb332ade3c
3 changed files with 922 additions and 74 deletions

View File

@@ -0,0 +1,146 @@
{
"identifier" : "D8F3A2B1-4E5C-6D7F-8A9B-0C1D2E3F4A5B",
"nonRenewingSubscriptions" : [
],
"products" : [
],
"settings" : {
"_applicationInternalID" : "6741234567",
"_developerTeamID" : "V3PF3M6B6U",
"_failTransactionsEnabled" : false,
"_locale" : "en_US",
"_storefront" : "USA",
"_storeKitErrors" : [
{
"current" : null,
"enabled" : false,
"name" : "Load Products"
},
{
"current" : null,
"enabled" : false,
"name" : "Purchase"
},
{
"current" : null,
"enabled" : false,
"name" : "Verification"
},
{
"current" : null,
"enabled" : false,
"name" : "App Store Sync"
},
{
"current" : null,
"enabled" : false,
"name" : "Subscription Status"
},
{
"current" : null,
"enabled" : false,
"name" : "App Transaction"
},
{
"current" : null,
"enabled" : false,
"name" : "Manage Subscriptions Sheet"
},
{
"current" : null,
"enabled" : false,
"name" : "Refund Request Sheet"
},
{
"current" : null,
"enabled" : false,
"name" : "Offer Code Redeem Sheet"
}
]
},
"subscriptionGroups" : [
{
"id" : "21514523",
"localizations" : [
],
"name" : "SportsTime Pro",
"subscriptions" : [
{
"adHocOffers" : [
],
"codeOffers" : [
],
"displayPrice" : "2.99",
"familyShareable" : false,
"groupNumber" : 1,
"internalID" : "6741234568",
"introductoryOffer" : {
"displayPrice" : "0.99",
"internalID" : "6741234569",
"numberOfPeriods" : 1,
"paymentMode" : "payAsYouGo",
"subscriptionPeriod" : "P1M"
},
"localizations" : [
{
"description" : "Unlimited trips, PDF export, and progress tracking",
"displayName" : "Monthly",
"locale" : "en_US"
}
],
"productID" : "com.sportstime.pro.monthly",
"recurringSubscriptionPeriod" : "P1M",
"referenceName" : "Pro Monthly",
"subscriptionGroupID" : "21514523",
"type" : "RecurringSubscription",
"winbackOffers" : [
]
},
{
"adHocOffers" : [
],
"codeOffers" : [
],
"displayPrice" : "29.99",
"familyShareable" : false,
"groupNumber" : 1,
"internalID" : "6741234570",
"introductoryOffer" : {
"displayPrice" : "19.99",
"internalID" : "6741234571",
"numberOfPeriods" : 1,
"paymentMode" : "payAsYouGo",
"subscriptionPeriod" : "P1Y"
},
"localizations" : [
{
"description" : "Unlimited trips, PDF export, and progress tracking - Best Value!",
"displayName" : "Annual",
"locale" : "en_US"
}
],
"productID" : "com.sportstime.pro.annual",
"recurringSubscriptionPeriod" : "P1Y",
"referenceName" : "Pro Annual",
"subscriptionGroupID" : "21514523",
"type" : "RecurringSubscription",
"winbackOffers" : [
]
}
]
}
],
"version" : {
"major" : 4,
"minor" : 0
}
}

View File

@@ -8,6 +8,501 @@
import SwiftUI import SwiftUI
import StoreKit 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)
}
.onAppear {
withAnimation(.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))
.position(
x: geo.size.width * (0.15 + position * 0.7),
y: geo.size.height * (0.2 + sin(position * .pi * 2) * 0.15)
)
}
.onAppear {
withAnimation(.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)
}
// 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)
}
}
.onAppear {
withAnimation(.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
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(.system(size: 18, weight: .bold, design: .rounded))
Text("/")
.font(.system(size: 14))
Text("12")
.font(.system(size: 14, weight: .medium, design: .rounded))
}
.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)
}
}
.onAppear {
withAnimation(.easeInOut(duration: 2.5).repeatForever(autoreverses: true)) {
animate = true
}
withAnimation(.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
)
}
}
}
/// Premium pricing background with sports icons
struct PricingBackground: View {
@State private var animate = false
var body: some View {
ZStack {
// Floating sports icons with glow effects
ForEach(0..<12, id: \.self) { index in
SportsIconWithGlow(index: index, animate: animate)
}
}
.onAppear {
withAnimation(.easeInOut(duration: 2).repeatForever(autoreverses: true)) {
animate = true
}
}
}
}
/// Individual sports icon with random glow/flash animation
private struct SportsIconWithGlow: View {
let index: Int
let animate: Bool
@State private var isGlowing = false
@State private var glowOpacity: Double = 0
private let configs: [(x: CGFloat, y: CGFloat, icon: String, rotation: Double, scale: CGFloat)] = [
(0.08, 0.1, "football.fill", -20, 0.95),
(0.92, 0.12, "basketball.fill", 15, 0.9),
(0.05, 0.35, "baseball.fill", 10, 0.85),
(0.95, 0.38, "hockey.puck.fill", -12, 0.8),
(0.1, 0.55, "soccerball", 8, 0.9),
(0.9, 0.52, "figure.run", -8, 0.95),
(0.06, 0.75, "sportscourt.fill", 5, 0.85),
(0.94, 0.78, "trophy.fill", -15, 0.9),
(0.12, 0.92, "ticket.fill", 12, 0.8),
(0.88, 0.95, "mappin.circle.fill", -10, 0.85),
(0.5, 0.05, "car.fill", 0, 0.8),
(0.5, 0.98, "map.fill", 5, 0.85),
]
var body: some View {
let config = configs[index]
GeometryReader { geo in
ZStack {
// Glow circle behind icon when active
Circle()
.fill(Theme.warmOrange)
.frame(width: 40 * config.scale, height: 40 * config.scale)
.blur(radius: 12)
.opacity(glowOpacity * 0.4)
Image(systemName: config.icon)
.font(.system(size: 26 * config.scale))
.foregroundStyle(Theme.warmOrange.opacity(0.15 + glowOpacity * 0.25))
.rotationEffect(.degrees(config.rotation))
}
.position(x: geo.size.width * config.x, y: geo.size.height * config.y)
.scaleEffect(animate ? 1.08 : 0.95)
.scaleEffect(1 + glowOpacity * 0.15)
.animation(
.easeInOut(duration: 2.5 + Double(index) * 0.1)
.repeatForever(autoreverses: true)
.delay(Double(index) * 0.1),
value: animate
)
}
.onAppear {
startRandomGlow()
}
}
private func startRandomGlow() {
// Random delay before first glow (stagger the icons)
let initialDelay = Double.random(in: 0.5...3.0)
DispatchQueue.main.asyncAfter(deadline: .now() + initialDelay) {
triggerGlow()
}
}
private func triggerGlow() {
// Glow on
withAnimation(.easeIn(duration: 0.3)) {
glowOpacity = 1
}
// Glow off after a moment
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
withAnimation(.easeOut(duration: 0.6)) {
glowOpacity = 0
}
}
// Schedule next glow with random interval
let nextGlow = Double.random(in: 2.5...6.0)
DispatchQueue.main.asyncAfter(deadline: .now() + nextGlow) {
triggerGlow()
}
}
}
struct OnboardingPaywallView: View { struct OnboardingPaywallView: View {
@Environment(\.colorScheme) private var colorScheme @Environment(\.colorScheme) private var colorScheme
@Binding var isPresented: Bool @Binding var isPresented: Bool
@@ -19,10 +514,43 @@ struct OnboardingPaywallView: View {
private let storeManager = StoreManager.shared private let storeManager = StoreManager.shared
private let pages: [(icon: String, title: String, description: String, color: Color)] = [ private let pages: [(icon: String, title: String, subtitle: String, bullets: [String], color: Color)] = [
("suitcase.fill", "Unlimited Trips", "Plan as many road trips as you want. Never lose your itineraries.", Theme.warmOrange), (
("doc.fill", "Export & Share", "Generate beautiful PDF itineraries to share with friends.", Theme.routeGold), "suitcase.fill",
("trophy.fill", "Track Your Journey", "Log stadium visits, earn badges, complete your bucket list.", .green) "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 { var body: some View {
@@ -68,90 +596,172 @@ struct OnboardingPaywallView: View {
private func featurePage(index: Int) -> some View { private func featurePage(index: Int) -> some View {
let page = pages[index] let page = pages[index]
return VStack(spacing: Theme.Spacing.xl) { return ZStack {
Spacer() // Themed background based on page
backgroundForPage(index: index, color: page.color)
ZStack { VStack(spacing: Theme.Spacing.lg) {
Circle() Spacer()
.fill(page.color.opacity(0.15))
.frame(width: 120, height: 120)
Image(systemName: page.icon) ZStack {
.font(.system(size: 50)) Circle()
.foregroundStyle(page.color) .fill(page.color.opacity(0.15))
.frame(width: 100, height: 100)
Image(systemName: page.icon)
.font(.system(size: 44))
.foregroundStyle(page.color)
}
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)
Text(bullet)
.font(.body)
.foregroundStyle(Theme.textPrimary(colorScheme))
Spacer()
}
}
}
.padding(.horizontal, Theme.Spacing.xl)
.padding(.top, Theme.Spacing.md)
Spacer()
Spacer()
} }
}
}
VStack(spacing: Theme.Spacing.md) { @ViewBuilder
Text(page.title) private func backgroundForPage(index: Int, color: Color) -> some View {
.font(.title.bold()) switch index {
.foregroundStyle(Theme.textPrimary(colorScheme)) case 0:
// Unlimited Trips - route map with dotted paths
Text(page.description) TripRoutesBackground(color: color)
.font(.body) case 1:
.foregroundStyle(Theme.textSecondary(colorScheme)) // Export & Share - floating documents
.multilineTextAlignment(.center) DocumentsBackground(color: color)
.padding(.horizontal, Theme.Spacing.xl) case 2:
} // Track Your Journey - stadium map with pins
StadiumMapBackground(color: color)
Spacer() default:
Spacer() EmptyView()
} }
} }
// MARK: - Pricing Page // MARK: - Pricing Page
private var pricingPage: some View { private var pricingPage: some View {
VStack(spacing: Theme.Spacing.lg) { ZStack {
Spacer() // Premium background
PricingBackground()
Text("Choose Your Plan") VStack(spacing: Theme.Spacing.lg) {
.font(.title.bold()) Spacer()
.foregroundStyle(Theme.textPrimary(colorScheme))
if storeManager.isLoading { // Header with crown icon
ProgressView() VStack(spacing: Theme.Spacing.sm) {
} else { ZStack {
VStack(spacing: Theme.Spacing.md) { Circle()
// Annual (recommended) .fill(Theme.warmOrange.opacity(0.15))
if let annual = storeManager.annualProduct { .frame(width: 70, height: 70)
OnboardingPricingRow(
product: annual, Image(systemName: "crown.fill")
title: "Annual", .font(.system(size: 30))
subtitle: "Best Value - Save 17%", .foregroundStyle(Theme.warmOrange)
isSelected: selectedProduct?.id == annual.id,
isRecommended: true
) {
selectedProduct = annual
}
} }
// Monthly Text("Choose Your Plan")
if let monthly = storeManager.monthlyProduct { .font(.title.bold())
OnboardingPricingRow( .foregroundStyle(Theme.textPrimary(colorScheme))
product: monthly,
title: "Monthly", Text("Unlock the full SportsTime experience")
subtitle: "Flexible billing", .font(.subheadline)
isSelected: selectedProduct?.id == monthly.id, .foregroundStyle(Theme.textSecondary(colorScheme))
isRecommended: false
) {
selectedProduct = monthly
}
}
} }
.padding(.horizontal, Theme.Spacing.lg)
}
if let error = errorMessage { if storeManager.isLoading {
Text(error) ProgressView()
.font(.caption) } else {
.foregroundStyle(.red) VStack(spacing: Theme.Spacing.md) {
} // Annual (recommended)
if let annual = storeManager.annualProduct {
OnboardingPricingRow(
product: annual,
title: "Annual",
subtitle: "Best Value - Save 17%",
isSelected: selectedProduct?.id == annual.id,
isRecommended: true
) {
selectedProduct = annual
}
}
Spacer() // Monthly
Spacer() if let monthly = storeManager.monthlyProduct {
OnboardingPricingRow(
product: monthly,
title: "Monthly",
subtitle: "Flexible billing",
isSelected: selectedProduct?.id == monthly.id,
isRecommended: false
) {
selectedProduct = monthly
}
}
}
.padding(.horizontal, Theme.Spacing.lg)
}
if let error = errorMessage {
Text(error)
.font(.caption)
.foregroundStyle(.red)
}
// Features summary
HStack(spacing: Theme.Spacing.lg) {
featurePill(icon: "infinity", text: "Unlimited")
featurePill(icon: "doc.fill", text: "PDF Export")
featurePill(icon: "trophy.fill", text: "Progress")
}
.padding(.top, Theme.Spacing.md)
Spacer()
Spacer()
}
} }
} }
private func featurePill(icon: String, text: String) -> some View {
HStack(spacing: 4) {
Image(systemName: icon)
.font(.system(size: 11))
Text(text)
.font(.caption2)
}
.foregroundStyle(Theme.textMuted(colorScheme))
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Theme.warmOrange.opacity(0.08), in: Capsule())
}
// MARK: - Bottom Buttons // MARK: - Bottom Buttons
private var bottomButtons: some View { private var bottomButtons: some View {
@@ -256,6 +866,43 @@ struct OnboardingPricingRow: View {
@Environment(\.colorScheme) private var colorScheme @Environment(\.colorScheme) private var colorScheme
/// The introductory offer for this product, if available
private var introOffer: Product.SubscriptionOffer? {
product.subscription?.introductoryOffer
}
/// Whether this product has an intro offer to display
private var hasIntroOffer: Bool {
introOffer != nil
}
/// Format the intro offer period (e.g., "1 month", "1 year")
private var introPeriodText: String? {
guard let offer = introOffer else { return nil }
let unit = offer.period.unit
let value = offer.period.value
switch unit {
case .day: return value == 1 ? "day" : "\(value) days"
case .week: return value == 1 ? "week" : "\(value) weeks"
case .month: return value == 1 ? "month" : "\(value) months"
case .year: return value == 1 ? "year" : "\(value) years"
@unknown default: return nil
}
}
/// The regular period text (e.g., "/month", "/year")
private var regularPeriodText: String {
guard let period = product.subscription?.subscriptionPeriod else { return "" }
switch period.unit {
case .month: return "/month"
case .year: return "/year"
case .week: return "/week"
case .day: return "/day"
@unknown default: return ""
}
}
var body: some View { var body: some View {
Button(action: onSelect) { Button(action: onSelect) {
HStack { HStack {
@@ -275,16 +922,37 @@ struct OnboardingPricingRow: View {
} }
} }
Text(subtitle) if hasIntroOffer, let periodText = introPeriodText {
.font(.caption) Text("First \(periodText) at intro price")
.foregroundStyle(Theme.textSecondary(colorScheme)) .font(.caption)
.foregroundStyle(.green)
} else {
Text(subtitle)
.font(.caption)
.foregroundStyle(Theme.textSecondary(colorScheme))
}
} }
Spacer() Spacer()
Text(product.displayPrice) VStack(alignment: .trailing, spacing: 2) {
.font(.title3.bold()) if let offer = introOffer {
.foregroundStyle(Theme.textPrimary(colorScheme)) // Show intro price prominently
Text(offer.displayPrice)
.font(.title3.bold())
.foregroundStyle(Theme.textPrimary(colorScheme))
// Show regular price as "then $X.XX/period"
Text("then \(product.displayPrice)\(regularPeriodText)")
.font(.caption2)
.foregroundStyle(Theme.textMuted(colorScheme))
} else {
// No intro offer - show regular price
Text(product.displayPrice)
.font(.title3.bold())
.foregroundStyle(Theme.textPrimary(colorScheme))
}
}
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
.foregroundStyle(isSelected ? Theme.warmOrange : Theme.textMuted(colorScheme)) .foregroundStyle(isSelected ? Theme.warmOrange : Theme.textMuted(colorScheme))

View File

@@ -10,6 +10,7 @@ struct SettingsView: View {
@State private var viewModel = SettingsViewModel() @State private var viewModel = SettingsViewModel()
@State private var showResetConfirmation = false @State private var showResetConfirmation = false
@State private var showPaywall = false @State private var showPaywall = false
@State private var showOnboardingPaywall = false
var body: some View { var body: some View {
List { List {
@@ -33,6 +34,11 @@ struct SettingsView: View {
// Reset // Reset
resetSection resetSection
#if DEBUG
// Debug
debugSection
#endif
} }
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
.themedBackground() .themedBackground()
@@ -44,6 +50,9 @@ struct SettingsView: View {
} message: { } message: {
Text("This will reset all settings to their default values.") Text("This will reset all settings to their default values.")
} }
.sheet(isPresented: $showOnboardingPaywall) {
OnboardingPaywallView(isPresented: $showOnboardingPaywall)
}
} }
// MARK: - Theme Section // MARK: - Theme Section
@@ -246,6 +255,31 @@ struct SettingsView: View {
.listRowBackground(Theme.cardBackground(colorScheme)) .listRowBackground(Theme.cardBackground(colorScheme))
} }
// MARK: - Debug Section
#if DEBUG
private var debugSection: some View {
Section {
Button {
showOnboardingPaywall = true
} label: {
Label("Show Onboarding Flow", systemImage: "play.circle")
}
Button {
UserDefaults.standard.removeObject(forKey: "hasSeenOnboardingPaywall")
} label: {
Label("Reset Onboarding Flag", systemImage: "arrow.counterclockwise")
}
} header: {
Text("Debug")
} footer: {
Text("These options are only visible in debug builds.")
}
.listRowBackground(Theme.cardBackground(colorScheme))
}
#endif
// MARK: - Subscription Section // MARK: - Subscription Section
private var subscriptionSection: some View { private var subscriptionSection: some View {