Files
Sportstime/docs/plans/2026-01-13-in-app-purchase-implementation.md
Trey t 3d40145ffb docs: update planning documents and todos
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 13:16:52 -06:00

48 KiB

In-App Purchase Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Implement freemium subscription model with StoreKit 2 local-only entitlement checking.

Architecture: StoreManager singleton manages entitlements via Transaction.currentEntitlements. ProGate view modifier gates Pro features. PaywallView handles purchases.

Tech Stack: StoreKit 2, SwiftUI, @Observable


Task 1: Create StoreKit Configuration File

Files:

  • Create: SportsTime/SportsTime.storekit

Step 1: Create StoreKit configuration file in Xcode

Open Xcode → File → New → File → StoreKit Configuration File

Name it SportsTime.storekit and save to project root.

Step 2: Add subscription group

In the StoreKit file, click "+" → Add Subscription Group → Name: "Pro Access"

Step 3: Add monthly subscription

Inside "Pro Access" group, click "+" → Add Subscription:

  • Reference Name: "Pro Monthly"
  • Product ID: com.sportstime.pro.monthly
  • Price: $4.99
  • Duration: 1 Month
  • Family Sharing: Enabled

Step 4: Add annual subscription

Inside "Pro Access" group, click "+" → Add Subscription:

  • Reference Name: "Pro Annual"
  • Product ID: com.sportstime.pro.annual
  • Price: $49.99
  • Duration: 1 Year
  • Family Sharing: Enabled

Step 5: Configure scheme to use StoreKit config

Edit Scheme → Run → Options → StoreKit Configuration → Select SportsTime.storekit


Task 2: Create ProFeature Enum

Files:

  • Create: SportsTime/SportsTime/Core/Store/ProFeature.swift
  • Create: SportsTime/SportsTimeTests/Store/ProFeatureTests.swift

Step 1: Write the test

Create SportsTime/SportsTimeTests/Store/ProFeatureTests.swift:

//
//  ProFeatureTests.swift
//  SportsTimeTests
//

import Testing
@testable import SportsTime

struct ProFeatureTests {
    @Test func allCases_containsExpectedFeatures() {
        let features = ProFeature.allCases
        #expect(features.contains(.unlimitedTrips))
        #expect(features.contains(.pdfExport))
        #expect(features.contains(.progressTracking))
        #expect(features.count == 3)
    }

    @Test func displayName_returnsHumanReadableString() {
        #expect(ProFeature.unlimitedTrips.displayName == "Unlimited Trips")
        #expect(ProFeature.pdfExport.displayName == "PDF Export")
        #expect(ProFeature.progressTracking.displayName == "Progress Tracking")
    }

    @Test func description_returnsMarketingCopy() {
        #expect(ProFeature.unlimitedTrips.description.contains("trips"))
        #expect(ProFeature.pdfExport.description.contains("PDF"))
        #expect(ProFeature.progressTracking.description.contains("stadium"))
    }

    @Test func icon_returnsValidSFSymbol() {
        #expect(!ProFeature.unlimitedTrips.icon.isEmpty)
        #expect(!ProFeature.pdfExport.icon.isEmpty)
        #expect(!ProFeature.progressTracking.icon.isEmpty)
    }
}

Step 2: Write implementation

Create SportsTime/SportsTime/Core/Store/ProFeature.swift:

//
//  ProFeature.swift
//  SportsTime
//
//  Defines features gated behind Pro subscription.
//

import Foundation

enum ProFeature: String, CaseIterable, Identifiable {
    case unlimitedTrips
    case pdfExport
    case progressTracking

    var id: String { rawValue }

    var displayName: String {
        switch self {
        case .unlimitedTrips: return "Unlimited Trips"
        case .pdfExport: return "PDF Export"
        case .progressTracking: return "Progress Tracking"
        }
    }

    var description: String {
        switch self {
        case .unlimitedTrips:
            return "Save unlimited trips and never lose your itineraries."
        case .pdfExport:
            return "Export beautiful PDF itineraries to share with friends."
        case .progressTracking:
            return "Track stadium visits, earn badges, complete your bucket list."
        }
    }

    var icon: String {
        switch self {
        case .unlimitedTrips: return "suitcase.fill"
        case .pdfExport: return "doc.fill"
        case .progressTracking: return "trophy.fill"
        }
    }
}

Task 3: Create StoreError Enum

Files:

  • Create: SportsTime/SportsTime/Core/Store/StoreError.swift
  • Create: SportsTime/SportsTimeTests/Store/StoreErrorTests.swift

Step 1: Write the test

Create SportsTime/SportsTimeTests/Store/StoreErrorTests.swift:

//
//  StoreErrorTests.swift
//  SportsTimeTests
//

import Testing
@testable import SportsTime

struct StoreErrorTests {
    @Test func errorDescription_returnsUserFriendlyMessage() {
        #expect(StoreError.productNotFound.localizedDescription.contains("not found"))
        #expect(StoreError.purchaseFailed.localizedDescription.contains("failed"))
        #expect(StoreError.verificationFailed.localizedDescription.contains("verify"))
    }
}

Step 2: Write implementation

Create SportsTime/SportsTime/Core/Store/StoreError.swift:

//
//  StoreError.swift
//  SportsTime
//

import Foundation

enum StoreError: LocalizedError {
    case productNotFound
    case purchaseFailed
    case verificationFailed
    case userCancelled

    var errorDescription: String? {
        switch self {
        case .productNotFound:
            return "Product not found. Please try again later."
        case .purchaseFailed:
            return "Purchase failed. Please try again."
        case .verificationFailed:
            return "Could not verify purchase. Please contact support."
        case .userCancelled:
            return nil // User cancelled, no error message needed
        }
    }
}

Task 4: Create StoreManager Core

Files:

  • Create: SportsTime/SportsTime/Core/Store/StoreManager.swift
  • Create: SportsTime/SportsTimeTests/Store/StoreManagerTests.swift

Step 1: Write the test

Create SportsTime/SportsTimeTests/Store/StoreManagerTests.swift:

//
//  StoreManagerTests.swift
//  SportsTimeTests
//

import Testing
import StoreKit
@testable import SportsTime

struct StoreManagerTests {
    @Test func shared_returnsSingletonInstance() {
        let instance1 = StoreManager.shared
        let instance2 = StoreManager.shared
        #expect(instance1 === instance2)
    }

    @Test func isPro_returnsFalseWhenNoPurchases() {
        let manager = StoreManager.shared
        // Fresh state should not be Pro
        // Note: In real tests, we'd reset state first
        #expect(manager.isPro == false || manager.isPro == true) // Just verify it's accessible
    }

    @Test func proProductIDs_containsExpectedProducts() {
        #expect(StoreManager.proProductIDs.contains("com.sportstime.pro.monthly"))
        #expect(StoreManager.proProductIDs.contains("com.sportstime.pro.annual"))
        #expect(StoreManager.proProductIDs.count == 2)
    }

    @Test func freeTripLimit_returnsOne() {
        #expect(StoreManager.freeTripLimit == 1)
    }
}

Step 2: Write implementation

Create SportsTime/SportsTime/Core/Store/StoreManager.swift:

//
//  StoreManager.swift
//  SportsTime
//
//  Manages StoreKit 2 subscriptions and entitlements.
//

import Foundation
import StoreKit

@Observable
@MainActor
final class StoreManager {
    // MARK: - Singleton

    static let shared = StoreManager()

    // MARK: - Constants

    static let proProductIDs: Set<String> = [
        "com.sportstime.pro.monthly",
        "com.sportstime.pro.annual"
    ]

    static let freeTripLimit = 1

    // MARK: - Published State

    private(set) var products: [Product] = []
    private(set) var purchasedProductIDs: Set<String> = []
    private(set) var isLoading = false
    private(set) var error: StoreError?

    // MARK: - Computed Properties

    var isPro: Bool {
        !purchasedProductIDs.intersection(Self.proProductIDs).isEmpty
    }

    var monthlyProduct: Product? {
        products.first { $0.id == "com.sportstime.pro.monthly" }
    }

    var annualProduct: Product? {
        products.first { $0.id == "com.sportstime.pro.annual" }
    }

    // MARK: - Initialization

    private init() {}

    // MARK: - Product Loading

    func loadProducts() async {
        isLoading = true
        error = nil

        do {
            products = try await Product.products(for: Self.proProductIDs)
            isLoading = false
        } catch {
            self.error = .productNotFound
            isLoading = false
        }
    }

    // MARK: - Entitlement Management

    func updateEntitlements() async {
        var purchased: Set<String> = []

        for await result in Transaction.currentEntitlements {
            if case .verified(let transaction) = result {
                purchased.insert(transaction.productID)
            }
        }

        purchasedProductIDs = purchased
    }

    // MARK: - Purchase

    func purchase(_ product: Product) async throws {
        let result = try await product.purchase()

        switch result {
        case .success(let verification):
            let transaction = try checkVerified(verification)
            await transaction.finish()
            await updateEntitlements()

        case .userCancelled:
            throw StoreError.userCancelled

        case .pending:
            // Ask to Buy or SCA - transaction will appear in updates when approved
            break

        @unknown default:
            throw StoreError.purchaseFailed
        }
    }

    // MARK: - Restore

    func restorePurchases() async {
        do {
            try await AppStore.sync()
        } catch {
            // Sync failed, but we can still check current entitlements
        }
        await updateEntitlements()
    }

    // MARK: - Transaction Listener

    func listenForTransactions() -> Task<Void, Never> {
        Task.detached {
            for await result in Transaction.updates {
                if case .verified(let transaction) = result {
                    await transaction.finish()
                    await MainActor.run {
                        Task {
                            await StoreManager.shared.updateEntitlements()
                        }
                    }
                }
            }
        }
    }

    // MARK: - Helpers

    private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
        switch result {
        case .unverified:
            throw StoreError.verificationFailed
        case .verified(let safe):
            return safe
        }
    }
}

Task 5: Create ProBadge View

Files:

  • Create: SportsTime/SportsTime/Features/Paywall/Views/ProBadge.swift

Step 1: Write the view

Create SportsTime/SportsTime/Features/Paywall/Views/ProBadge.swift:

//
//  ProBadge.swift
//  SportsTime
//
//  Small "PRO" badge indicator for locked features.
//

import SwiftUI

struct ProBadge: View {
    var body: some View {
        Text("PRO")
            .font(.caption2.bold())
            .foregroundStyle(.white)
            .padding(.horizontal, 6)
            .padding(.vertical, 2)
            .background(Theme.warmOrange, in: Capsule())
    }
}

// MARK: - View Modifier

extension View {
    /// Adds a small PRO badge overlay to indicate locked feature
    func proBadge(alignment: Alignment = .topTrailing) -> some View {
        overlay(alignment: alignment) {
            if !StoreManager.shared.isPro {
                ProBadge()
                    .padding(4)
            }
        }
    }
}

#Preview {
    VStack(spacing: 20) {
        ProBadge()

        // Example usage
        RoundedRectangle(cornerRadius: 12)
            .fill(.blue.opacity(0.2))
            .frame(width: 100, height: 60)
            .proBadge()
    }
    .padding()
}

Task 6: Create PaywallView

Files:

  • Create: SportsTime/SportsTime/Features/Paywall/Views/PaywallView.swift

Step 1: Write the view

Create SportsTime/SportsTime/Features/Paywall/Views/PaywallView.swift:

//
//  PaywallView.swift
//  SportsTime
//
//  Full-screen paywall for Pro subscription.
//

import SwiftUI
import StoreKit

struct PaywallView: View {
    @Environment(\.colorScheme) private var colorScheme
    @Environment(\.dismiss) private var dismiss

    @State private var selectedProduct: Product?
    @State private var isPurchasing = false
    @State private var errorMessage: String?

    private let storeManager = StoreManager.shared

    var body: some View {
        NavigationStack {
            ScrollView {
                VStack(spacing: Theme.Spacing.xl) {
                    // Hero
                    heroSection

                    // Features
                    featuresSection

                    // Pricing
                    pricingSection

                    // Error message
                    if let error = errorMessage {
                        Text(error)
                            .font(.subheadline)
                            .foregroundStyle(.red)
                            .multilineTextAlignment(.center)
                            .padding(.horizontal)
                    }

                    // Subscribe button
                    subscribeButton

                    // Restore purchases
                    restoreButton

                    // Legal
                    legalSection
                }
                .padding(Theme.Spacing.lg)
            }
            .themedBackground()
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .cancellationAction) {
                    Button {
                        dismiss()
                    } label: {
                        Image(systemName: "xmark.circle.fill")
                            .foregroundStyle(Theme.textMuted(colorScheme))
                    }
                }
            }
            .task {
                await storeManager.loadProducts()
                // Default to annual
                selectedProduct = storeManager.annualProduct ?? storeManager.monthlyProduct
            }
        }
    }

    // MARK: - Hero Section

    private var heroSection: some View {
        VStack(spacing: Theme.Spacing.md) {
            Image(systemName: "star.circle.fill")
                .font(.system(size: 60))
                .foregroundStyle(Theme.warmOrange)

            Text("Upgrade to Pro")
                .font(.largeTitle.bold())
                .foregroundStyle(Theme.textPrimary(colorScheme))

            Text("Unlock the full SportsTime experience")
                .font(.body)
                .foregroundStyle(Theme.textSecondary(colorScheme))
                .multilineTextAlignment(.center)
        }
    }

    // MARK: - Features Section

    private var featuresSection: some View {
        VStack(spacing: Theme.Spacing.md) {
            ForEach(ProFeature.allCases) { feature in
                HStack(spacing: Theme.Spacing.md) {
                    ZStack {
                        Circle()
                            .fill(Theme.warmOrange.opacity(0.15))
                            .frame(width: 44, height: 44)

                        Image(systemName: feature.icon)
                            .foregroundStyle(Theme.warmOrange)
                    }

                    VStack(alignment: .leading, spacing: 4) {
                        Text(feature.displayName)
                            .font(.body)
                            .foregroundStyle(Theme.textPrimary(colorScheme))

                        Text(feature.description)
                            .font(.subheadline)
                            .foregroundStyle(Theme.textSecondary(colorScheme))
                    }

                    Spacer()

                    Image(systemName: "checkmark.circle.fill")
                        .foregroundStyle(.green)
                }
                .padding(Theme.Spacing.md)
                .background(Theme.cardBackground(colorScheme))
                .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
            }
        }
    }

    // MARK: - Pricing Section

    private var pricingSection: some View {
        VStack(spacing: Theme.Spacing.md) {
            if storeManager.isLoading {
                ProgressView()
                    .padding()
            } else {
                HStack(spacing: Theme.Spacing.md) {
                    // Monthly option
                    if let monthly = storeManager.monthlyProduct {
                        PricingOptionCard(
                            product: monthly,
                            title: "Monthly",
                            isSelected: selectedProduct?.id == monthly.id,
                            savingsText: nil
                        ) {
                            selectedProduct = monthly
                        }
                    }

                    // Annual option
                    if let annual = storeManager.annualProduct {
                        PricingOptionCard(
                            product: annual,
                            title: "Annual",
                            isSelected: selectedProduct?.id == annual.id,
                            savingsText: "Save 17%"
                        ) {
                            selectedProduct = annual
                        }
                    }
                }
            }
        }
    }

    // MARK: - Subscribe Button

    private var subscribeButton: some View {
        Button {
            Task {
                await purchase()
            }
        } label: {
            HStack {
                if isPurchasing {
                    ProgressView()
                        .tint(.white)
                } else {
                    Text("Subscribe Now")
                        .fontWeight(.semibold)
                }
            }
            .frame(maxWidth: .infinity)
            .padding(Theme.Spacing.md)
            .background(Theme.warmOrange)
            .foregroundStyle(.white)
            .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
        }
        .disabled(selectedProduct == nil || isPurchasing)
        .opacity(selectedProduct == nil ? 0.5 : 1)
    }

    // MARK: - Restore Button

    private var restoreButton: some View {
        Button {
            Task {
                await storeManager.restorePurchases()
                if storeManager.isPro {
                    dismiss()
                } else {
                    errorMessage = "No active subscription found."
                }
            }
        } label: {
            Text("Restore Purchases")
                .font(.subheadline)
                .foregroundStyle(Theme.warmOrange)
        }
    }

    // MARK: - Legal Section

    private var legalSection: some View {
        VStack(spacing: Theme.Spacing.xs) {
            Text("Subscriptions automatically renew unless cancelled at least 24 hours before the end of the current period.")
                .font(.caption)
                .foregroundStyle(Theme.textMuted(colorScheme))
                .multilineTextAlignment(.center)

            HStack(spacing: Theme.Spacing.md) {
                Link("Terms", destination: URL(string: "https://88oakapps.com/terms")!)
                Link("Privacy", destination: URL(string: "https://88oakapps.com/privacy")!)
            }
            .font(.caption)
            .foregroundStyle(Theme.warmOrange)
        }
        .padding(.top, Theme.Spacing.md)
    }

    // MARK: - Actions

    private func purchase() async {
        guard let product = selectedProduct else { return }

        isPurchasing = true
        errorMessage = nil

        do {
            try await storeManager.purchase(product)
            dismiss()
        } catch StoreError.userCancelled {
            // User cancelled, no error message
        } catch {
            errorMessage = error.localizedDescription
        }

        isPurchasing = false
    }
}

// MARK: - Pricing Option Card

struct PricingOptionCard: View {
    let product: Product
    let title: String
    let isSelected: Bool
    let savingsText: String?
    let onSelect: () -> Void

    @Environment(\.colorScheme) private var colorScheme

    var body: some View {
        Button(action: onSelect) {
            VStack(spacing: Theme.Spacing.sm) {
                if let savings = savingsText {
                    Text(savings)
                        .font(.caption.bold())
                        .foregroundStyle(.white)
                        .padding(.horizontal, 8)
                        .padding(.vertical, 4)
                        .background(Theme.warmOrange, in: Capsule())
                }

                Text(title)
                    .font(.subheadline)
                    .foregroundStyle(Theme.textSecondary(colorScheme))

                Text(product.displayPrice)
                    .font(.title2.bold())
                    .foregroundStyle(Theme.textPrimary(colorScheme))

                Text(pricePerMonth)
                    .font(.caption)
                    .foregroundStyle(Theme.textMuted(colorScheme))
            }
            .frame(maxWidth: .infinity)
            .padding(Theme.Spacing.lg)
            .background(Theme.cardBackground(colorScheme))
            .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large))
            .overlay {
                RoundedRectangle(cornerRadius: Theme.CornerRadius.large)
                    .stroke(isSelected ? Theme.warmOrange : Theme.surfaceGlow(colorScheme), lineWidth: isSelected ? 2 : 1)
            }
        }
        .buttonStyle(.plain)
    }

    private var pricePerMonth: String {
        if product.id.contains("annual") {
            let monthly = product.price / 12
            return "\(monthly.formatted(.currency(code: product.priceFormatStyle.currencyCode ?? "USD")))/mo"
        }
        return "per month"
    }
}

#Preview {
    PaywallView()
}

Task 7: Create ProGate View Modifier

Files:

  • Create: SportsTime/SportsTime/Features/Paywall/ViewModifiers/ProGate.swift
  • Create: SportsTime/SportsTimeTests/Store/ProGateTests.swift

Step 1: Write the test

Create SportsTime/SportsTimeTests/Store/ProGateTests.swift:

//
//  ProGateTests.swift
//  SportsTimeTests
//

import Testing
import SwiftUI
@testable import SportsTime

struct ProGateTests {
    @Test func proGate_createsViewModifier() {
        // Just verify the modifier compiles and can be applied
        let _ = Text("Test").proGate(feature: .pdfExport)
        #expect(true) // If we got here, it compiles
    }
}

Step 2: Write implementation

Create SportsTime/SportsTime/Features/Paywall/ViewModifiers/ProGate.swift:

//
//  ProGate.swift
//  SportsTime
//
//  View modifier that gates Pro-only features.
//

import SwiftUI

struct ProGateModifier: ViewModifier {
    let feature: ProFeature

    @State private var showPaywall = false

    func body(content: Content) -> some View {
        content
            .onTapGesture {
                if !StoreManager.shared.isPro {
                    showPaywall = true
                }
            }
            .allowsHitTesting(!StoreManager.shared.isPro ? true : true)
            .overlay {
                if !StoreManager.shared.isPro {
                    Color.clear
                        .contentShape(Rectangle())
                        .onTapGesture {
                            showPaywall = true
                        }
                }
            }
            .sheet(isPresented: $showPaywall) {
                PaywallView()
            }
    }
}

/// Modifier for buttons that should show paywall when tapped by free users
struct ProGateButtonModifier: ViewModifier {
    let feature: ProFeature
    let action: () -> Void

    @State private var showPaywall = false

    func body(content: Content) -> some View {
        Button {
            if StoreManager.shared.isPro {
                action()
            } else {
                showPaywall = true
            }
        } label: {
            content
        }
        .sheet(isPresented: $showPaywall) {
            PaywallView()
        }
    }
}

extension View {
    /// Gates entire view - tapping shows paywall if not Pro
    func proGate(feature: ProFeature) -> some View {
        modifier(ProGateModifier(feature: feature))
    }

    /// Gates a button action - shows paywall instead of performing action if not Pro
    func proGateButton(feature: ProFeature, action: @escaping () -> Void) -> some View {
        modifier(ProGateButtonModifier(feature: feature, action: action))
    }
}

Task 8: Create OnboardingPaywallView

Files:

  • Create: SportsTime/SportsTime/Features/Paywall/Views/OnboardingPaywallView.swift

Step 1: Write the view

Create SportsTime/SportsTime/Features/Paywall/Views/OnboardingPaywallView.swift:

//
//  OnboardingPaywallView.swift
//  SportsTime
//
//  First-launch upsell with feature pages.
//

import SwiftUI
import StoreKit

struct OnboardingPaywallView: View {
    @Environment(\.colorScheme) private var colorScheme
    @Binding var isPresented: Bool

    @State private var currentPage = 0
    @State private var selectedProduct: Product?
    @State private var isPurchasing = false
    @State private var errorMessage: String?

    private let storeManager = StoreManager.shared

    private let pages: [(icon: String, title: String, description: 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),
        ("trophy.fill", "Track Your Journey", "Log stadium visits, earn badges, complete your bucket list.", .green)
    ]

    var body: some View {
        VStack(spacing: 0) {
            // Page content
            TabView(selection: $currentPage) {
                ForEach(0..<pages.count, id: \.self) { index in
                    featurePage(index: index)
                        .tag(index)
                }

                // Pricing page
                pricingPage
                    .tag(pages.count)
            }
            .tabViewStyle(.page(indexDisplayMode: .never))
            .animation(.easeInOut, value: currentPage)

            // Page indicator
            HStack(spacing: 8) {
                ForEach(0...pages.count, id: \.self) { index in
                    Circle()
                        .fill(currentPage == index ? Theme.warmOrange : Theme.textMuted(colorScheme))
                        .frame(width: 8, height: 8)
                }
            }
            .padding(.bottom, Theme.Spacing.lg)

            // Bottom buttons
            bottomButtons
                .padding(.horizontal, Theme.Spacing.lg)
                .padding(.bottom, Theme.Spacing.xl)
        }
        .background(Theme.backgroundGradient(colorScheme))
        .task {
            await storeManager.loadProducts()
            selectedProduct = storeManager.annualProduct ?? storeManager.monthlyProduct
        }
    }

    // MARK: - Feature Page

    private func featurePage(index: Int) -> some View {
        let page = pages[index]

        return VStack(spacing: Theme.Spacing.xl) {
            Spacer()

            ZStack {
                Circle()
                    .fill(page.color.opacity(0.15))
                    .frame(width: 120, height: 120)

                Image(systemName: page.icon)
                    .font(.system(size: 50))
                    .foregroundStyle(page.color)
            }

            VStack(spacing: Theme.Spacing.md) {
                Text(page.title)
                    .font(.title.bold())
                    .foregroundStyle(Theme.textPrimary(colorScheme))

                Text(page.description)
                    .font(.body)
                    .foregroundStyle(Theme.textSecondary(colorScheme))
                    .multilineTextAlignment(.center)
                    .padding(.horizontal, Theme.Spacing.xl)
            }

            Spacer()
            Spacer()
        }
    }

    // MARK: - Pricing Page

    private var pricingPage: some View {
        VStack(spacing: Theme.Spacing.lg) {
            Spacer()

            Text("Choose Your Plan")
                .font(.title.bold())
                .foregroundStyle(Theme.textPrimary(colorScheme))

            if storeManager.isLoading {
                ProgressView()
            } else {
                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
                        }
                    }

                    // Monthly
                    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)
            }

            Spacer()
            Spacer()
        }
    }

    // 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))
                }

                // Skip
                Button {
                    markOnboardingSeen()
                    isPresented = false
                } label: {
                    Text("Continue with Free")
                        .font(.subheadline)
                        .foregroundStyle(Theme.textMuted(colorScheme))
                }
            } else {
                // Subscribe button
                Button {
                    Task {
                        await purchase()
                    }
                } label: {
                    HStack {
                        if isPurchasing {
                            ProgressView()
                                .tint(.white)
                        } else {
                            Text("Subscribe")
                                .fontWeight(.semibold)
                        }
                    }
                    .frame(maxWidth: .infinity)
                    .padding(Theme.Spacing.md)
                    .background(Theme.warmOrange)
                    .foregroundStyle(.white)
                    .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
                }
                .disabled(selectedProduct == nil || isPurchasing)

                // Continue free
                Button {
                    markOnboardingSeen()
                    isPresented = false
                } label: {
                    Text("Continue with Free")
                        .font(.subheadline)
                        .foregroundStyle(Theme.textMuted(colorScheme))
                }
            }
        }
    }

    // MARK: - Actions

    private func purchase() async {
        guard let product = selectedProduct else { return }

        isPurchasing = true
        errorMessage = nil

        do {
            try await storeManager.purchase(product)
            markOnboardingSeen()
            isPresented = false
        } catch StoreError.userCancelled {
            // User cancelled
        } catch {
            errorMessage = error.localizedDescription
        }

        isPurchasing = false
    }

    private func markOnboardingSeen() {
        UserDefaults.standard.set(true, forKey: "hasSeenOnboardingPaywall")
    }
}

// MARK: - Pricing Row

struct OnboardingPricingRow: View {
    let product: Product
    let title: String
    let subtitle: String
    let isSelected: Bool
    let isRecommended: Bool
    let onSelect: () -> Void

    @Environment(\.colorScheme) private var colorScheme

    var body: some View {
        Button(action: onSelect) {
            HStack {
                VStack(alignment: .leading, spacing: 4) {
                    HStack(spacing: Theme.Spacing.xs) {
                        Text(title)
                            .font(.body.bold())
                            .foregroundStyle(Theme.textPrimary(colorScheme))

                        if isRecommended {
                            Text("BEST")
                                .font(.caption2.bold())
                                .foregroundStyle(.white)
                                .padding(.horizontal, 6)
                                .padding(.vertical, 2)
                                .background(Theme.warmOrange, in: Capsule())
                        }
                    }

                    Text(subtitle)
                        .font(.caption)
                        .foregroundStyle(Theme.textSecondary(colorScheme))
                }

                Spacer()

                Text(product.displayPrice)
                    .font(.title3.bold())
                    .foregroundStyle(Theme.textPrimary(colorScheme))

                Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
                    .foregroundStyle(isSelected ? Theme.warmOrange : Theme.textMuted(colorScheme))
            }
            .padding(Theme.Spacing.lg)
            .background(Theme.cardBackground(colorScheme))
            .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large))
            .overlay {
                RoundedRectangle(cornerRadius: Theme.CornerRadius.large)
                    .stroke(isSelected ? Theme.warmOrange : Theme.surfaceGlow(colorScheme), lineWidth: isSelected ? 2 : 1)
            }
        }
        .buttonStyle(.plain)
    }
}

#Preview {
    OnboardingPaywallView(isPresented: .constant(true))
}

Task 9: Integrate StoreManager in App Lifecycle

Files:

  • Modify: SportsTime/SportsTime/SportsTimeApp.swift

Step 1: Add transaction listener property and initialization

In SportsTimeApp.swift, add after sharedModelContainer (around line 45):

    /// Task that listens for StoreKit transaction updates
    private var transactionListener: Task<Void, Never>?

    init() {
        // Start listening for transactions immediately
        transactionListener = StoreManager.shared.listenForTransactions()
    }

Step 2: Update BootstrappedContentView to load store data

In performBootstrap() function around line 95, add store initialization after AppDataProvider.shared.loadInitialData():

            // 3. Load data from SwiftData into memory
            await AppDataProvider.shared.loadInitialData()

            // 4. Load store products and entitlements
            await StoreManager.shared.loadProducts()
            await StoreManager.shared.updateEntitlements()

            // 5. App is now usable
            isBootstrapping = false

Step 3: Add onboarding paywall state

In BootstrappedContentView, add state property:

    @State private var showOnboardingPaywall = false

    private var shouldShowOnboardingPaywall: Bool {
        !UserDefaults.standard.bool(forKey: "hasSeenOnboardingPaywall") && !StoreManager.shared.isPro
    }

Step 4: Show onboarding paywall after bootstrap

Update the body to show onboarding paywall:

            } else {
                HomeView()
                    .sheet(isPresented: $showOnboardingPaywall) {
                        OnboardingPaywallView(isPresented: $showOnboardingPaywall)
                            .interactiveDismissDisabled()
                    }
                    .onAppear {
                        if shouldShowOnboardingPaywall {
                            showOnboardingPaywall = true
                        }
                    }
            }

Task 10: Gate Trip Saving

Files:

  • Modify: SportsTime/SportsTime/Features/Trip/Views/TripDetailView.swift

Step 1: Add saved trip count query

At the top of TripDetailView, add after existing @State properties (around line 27):

    @Query private var savedTrips: [SavedTrip]

Step 2: Add paywall state

Add state for showing paywall:

    @State private var showProPaywall = false

Step 3: Modify saveTrip function

Replace the saveTrip() function (around line 525) with gated version:

    private func saveTrip() {
        // Check trip limit for free users
        if !StoreManager.shared.isPro && savedTrips.count >= StoreManager.freeTripLimit {
            showProPaywall = true
            return
        }

        guard let savedTrip = SavedTrip.from(trip, games: games, status: .planned) else {
            return
        }

        modelContext.insert(savedTrip)

        do {
            try modelContext.save()
            withAnimation(.spring(response: 0.3, dampingFraction: 0.6)) {
                isSaved = true
            }
        } catch {
            // Save failed silently
        }
    }

Step 4: Add paywall sheet

Add sheet modifier to the view body (after existing sheets around line 93):

        .sheet(isPresented: $showProPaywall) {
            PaywallView()
        }

Task 11: Gate PDF Export

Files:

  • Modify: SportsTime/SportsTime/Features/Trip/Views/TripDetailView.swift

Step 1: Modify export button in toolbar

Replace the PDF export button (around line 72) with gated version:

                Button {
                    if StoreManager.shared.isPro {
                        Task {
                            await exportPDF()
                        }
                    } else {
                        showProPaywall = true
                    }
                } label: {
                    HStack(spacing: 2) {
                        Image(systemName: "doc.fill")
                        if !StoreManager.shared.isPro {
                            ProBadge()
                        }
                    }
                    .foregroundStyle(Theme.warmOrange)
                }

Task 12: Gate Progress Tracking Tab

Files:

  • Modify: SportsTime/SportsTime/Features/Home/Views/HomeView.swift

Step 1: Add paywall state to HomeView

Add state property in HomeView (around line 19):

    @State private var showProPaywall = false

Step 2: Modify Progress tab to show paywall for free users

Replace the Progress tab section (around line 86) with:

            // Progress Tab
            NavigationStack {
                if StoreManager.shared.isPro {
                    ProgressTabView()
                } else {
                    ProLockedView(feature: .progressTracking) {
                        showProPaywall = true
                    }
                }
            }
            .tabItem {
                Label("Progress", systemImage: "chart.bar.fill")
            }
            .tag(3)

Step 3: Add ProLockedView

Create locked state view. Add at bottom of HomeView.swift:

// MARK: - Pro Locked View

struct ProLockedView: View {
    let feature: ProFeature
    let onUnlock: () -> Void

    @Environment(\.colorScheme) private var colorScheme

    var body: some View {
        VStack(spacing: Theme.Spacing.xl) {
            Spacer()

            ZStack {
                Circle()
                    .fill(Theme.warmOrange.opacity(0.15))
                    .frame(width: 100, height: 100)

                Image(systemName: "lock.fill")
                    .font(.system(size: 40))
                    .foregroundStyle(Theme.warmOrange)
            }

            VStack(spacing: Theme.Spacing.sm) {
                Text(feature.displayName)
                    .font(.title2.bold())
                    .foregroundStyle(Theme.textPrimary(colorScheme))

                Text(feature.description)
                    .font(.body)
                    .foregroundStyle(Theme.textSecondary(colorScheme))
                    .multilineTextAlignment(.center)
                    .padding(.horizontal, Theme.Spacing.xl)
            }

            Button {
                onUnlock()
            } label: {
                HStack {
                    Image(systemName: "star.fill")
                    Text("Upgrade to Pro")
                }
                .fontWeight(.semibold)
                .frame(maxWidth: .infinity)
                .padding(Theme.Spacing.md)
                .background(Theme.warmOrange)
                .foregroundStyle(.white)
                .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
            }
            .padding(.horizontal, Theme.Spacing.xl)

            Spacer()
            Spacer()
        }
        .themedBackground()
    }
}

Step 4: Add paywall sheet to HomeView

Add after the existing sheet modifiers (around line 123):

        .sheet(isPresented: $showProPaywall) {
            PaywallView()
        }

Task 13: Add Subscription Management to Settings

Files:

  • Modify: SportsTime/SportsTime/Features/Settings/Views/SettingsView.swift

Step 1: Add subscription section

Add new section after aboutSection (around line 173):

            // Subscription
            subscriptionSection

Step 2: Add showPaywall state

Add at top of SettingsView with other @State properties:

    @State private var showPaywall = false

Step 3: Implement subscription section

Add the section implementation:

    // MARK: - Subscription Section

    private var subscriptionSection: some View {
        Section {
            if StoreManager.shared.isPro {
                // Pro user - show manage option
                HStack {
                    Label {
                        VStack(alignment: .leading, spacing: 4) {
                            Text("SportsTime Pro")
                                .foregroundStyle(Theme.textPrimary(colorScheme))
                            Text("Active subscription")
                                .font(.caption)
                                .foregroundStyle(.green)
                        }
                    } icon: {
                        Image(systemName: "star.fill")
                            .foregroundStyle(Theme.warmOrange)
                    }

                    Spacer()

                    Image(systemName: "checkmark.circle.fill")
                        .foregroundStyle(.green)
                }

                Button {
                    if let url = URL(string: "https://apps.apple.com/account/subscriptions") {
                        UIApplication.shared.open(url)
                    }
                } label: {
                    Label("Manage Subscription", systemImage: "gear")
                }
            } else {
                // Free user - show upgrade option
                Button {
                    showPaywall = true
                } label: {
                    HStack {
                        Label {
                            VStack(alignment: .leading, spacing: 4) {
                                Text("Upgrade to Pro")
                                    .foregroundStyle(Theme.textPrimary(colorScheme))
                                Text("Unlimited trips, PDF export, progress tracking")
                                    .font(.caption)
                                    .foregroundStyle(Theme.textSecondary(colorScheme))
                            }
                        } icon: {
                            Image(systemName: "star.fill")
                                .foregroundStyle(Theme.warmOrange)
                        }

                        Spacer()

                        Image(systemName: "chevron.right")
                            .foregroundStyle(Theme.textMuted(colorScheme))
                    }
                }
                .buttonStyle(.plain)

                Button {
                    Task {
                        await StoreManager.shared.restorePurchases()
                    }
                } label: {
                    Label("Restore Purchases", systemImage: "arrow.clockwise")
                }
            }
        } header: {
            Text("Subscription")
        }
        .listRowBackground(Theme.cardBackground(colorScheme))
        .sheet(isPresented: $showPaywall) {
            PaywallView()
        }
    }

Task 14: Build, Test, and Fix Issues

Step 1: Build the project

xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build

Fix any compilation errors that arise.

Step 2: Run all tests

xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' test

Fix any test failures.

Step 3: Manual testing checklist

Test in Simulator with StoreKit configuration:

  • App launches and shows onboarding paywall on first run
  • Can dismiss onboarding with "Continue with Free"
  • Home tab shows normally
  • Progress tab shows locked view for free users
  • Can tap "Upgrade to Pro" on locked Progress tab
  • Paywall displays with monthly/annual options
  • Annual is pre-selected
  • Can purchase subscription (StoreKit sandbox)
  • After purchase, Progress tab unlocks
  • Can save 1 trip as free user
  • Saving 2nd trip shows paywall
  • PDF export button shows PRO badge for free users
  • Tapping PDF shows paywall
  • Settings shows "Upgrade to Pro" for free users
  • Settings shows "Manage Subscription" for Pro users
  • Restore Purchases works

Step 4: Commit all changes

git add -A
git commit -m "feat(iap): complete In-App Purchase implementation

- StoreKit 2 configuration with monthly/annual subscriptions
- StoreManager singleton for entitlement checking
- PaywallView with pricing options
- OnboardingPaywallView for first-launch upsell
- ProGate view modifier for feature gating
- Trip saving limited to 1 for free users
- PDF export gated behind Pro
- Progress tracking tab gated behind Pro
- Subscription management in Settings
- Family Sharing enabled"

Summary

Files Created:

  • SportsTime/SportsTime.storekit — StoreKit configuration
  • SportsTime/SportsTime/Core/Store/ProFeature.swift
  • SportsTime/SportsTime/Core/Store/StoreError.swift
  • SportsTime/SportsTime/Core/Store/StoreManager.swift
  • SportsTime/SportsTime/Features/Paywall/Views/ProBadge.swift
  • SportsTime/SportsTime/Features/Paywall/Views/PaywallView.swift
  • SportsTime/SportsTime/Features/Paywall/Views/OnboardingPaywallView.swift
  • SportsTime/SportsTime/Features/Paywall/ViewModifiers/ProGate.swift
  • SportsTimeTests/Store/ProFeatureTests.swift
  • SportsTimeTests/Store/StoreErrorTests.swift
  • SportsTimeTests/Store/StoreManagerTests.swift
  • SportsTimeTests/Store/ProGateTests.swift

Files Modified:

  • SportsTime/SportsTime/SportsTimeApp.swift
  • SportsTime/SportsTime/Features/Trip/Views/TripDetailView.swift
  • SportsTime/SportsTime/Features/Home/Views/HomeView.swift
  • SportsTime/SportsTime/Features/Settings/Views/SettingsView.swift