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

@@ -0,0 +1,43 @@
//
// 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"
}
}
}

View File

@@ -0,0 +1,26 @@
//
// 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
}
}
}

View File

@@ -0,0 +1,142 @@
//
// 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
}
}
}