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:
146
SportsTime/Configuration.storekit
Normal file
146
SportsTime/Configuration.storekit
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user