# 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`: ```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`: ```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`: ```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`: ```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`: ```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`: ```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 = [ "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 = [] 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 = [] 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 { 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(_ result: VerificationResult) 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`: ```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`: ```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`: ```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`: ```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`: ```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.. 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): ```swift /// Task that listens for StoreKit transaction updates private var transactionListener: Task? 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()`: ```swift // 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: ```swift @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: ```swift } 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): ```swift @Query private var savedTrips: [SavedTrip] ``` **Step 2: Add paywall state** Add state for showing paywall: ```swift @State private var showProPaywall = false ``` **Step 3: Modify saveTrip function** Replace the `saveTrip()` function (around line 525) with gated version: ```swift 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): ```swift .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: ```swift 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): ```swift @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: ```swift // 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: ```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): ```swift .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): ```swift // Subscription subscriptionSection ``` **Step 2: Add showPaywall state** Add at top of SettingsView with other @State properties: ```swift @State private var showPaywall = false ``` **Step 3: Implement subscription section** Add the section implementation: ```swift // 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** ```bash 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** ```bash 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** ```bash 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`