feat(store): add In-App Purchase system with Pro subscription

Implement freemium model with StoreKit 2:
- StoreManager singleton for purchase/restore/entitlements
- ProFeature enum defining gated features
- PaywallView and OnboardingPaywallView for upsell UI
- ProGate view modifier and ProBadge component

Feature gating:
- Trip saving: 1 free trip, then requires Pro
- PDF export: Pro only with badge indicator
- Progress tab: Shows ProLockedView for free users
- Settings: Subscription management section

Also fixes pre-existing test issues with StadiumVisit
and ItineraryOption model signature changes.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-01-13 11:41:40 -06:00
parent e4204175ea
commit 22772fa57f
19 changed files with 1293 additions and 34 deletions

View File

@@ -10,6 +10,14 @@ import SwiftData
@main
struct SportsTimeApp: App {
/// Task that listens for StoreKit transaction updates
private var transactionListener: Task<Void, Never>?
init() {
// Start listening for transactions immediately
transactionListener = StoreManager.shared.listenForTransactions()
}
var sharedModelContainer: ModelContainer = {
let schema = Schema([
// User data models
@@ -63,6 +71,11 @@ struct BootstrappedContentView: View {
@State private var isBootstrapping = true
@State private var bootstrapError: Error?
@State private var hasCompletedInitialSync = false
@State private var showOnboardingPaywall = false
private var shouldShowOnboardingPaywall: Bool {
!UserDefaults.standard.bool(forKey: "hasSeenOnboardingPaywall") && !StoreManager.shared.isPro
}
var body: some View {
Group {
@@ -76,6 +89,15 @@ struct BootstrappedContentView: View {
}
} else {
HomeView()
.sheet(isPresented: $showOnboardingPaywall) {
OnboardingPaywallView(isPresented: $showOnboardingPaywall)
.interactiveDismissDisabled()
}
.onAppear {
if shouldShowOnboardingPaywall {
showOnboardingPaywall = true
}
}
}
}
.task {
@@ -109,10 +131,14 @@ struct BootstrappedContentView: View {
// 3. Load data from SwiftData into memory
await AppDataProvider.shared.loadInitialData()
// 4. App is now usable
// 4. Load store products and entitlements
await StoreManager.shared.loadProducts()
await StoreManager.shared.updateEntitlements()
// 5. App is now usable
isBootstrapping = false
// 5. Background: Try to refresh from CloudKit (non-blocking)
// 6. Background: Try to refresh from CloudKit (non-blocking)
Task.detached(priority: .background) {
await self.performBackgroundSync(context: context)
await MainActor.run {