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:
43
SportsTime/Core/Store/ProFeature.swift
Normal file
43
SportsTime/Core/Store/ProFeature.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
26
SportsTime/Core/Store/StoreError.swift
Normal file
26
SportsTime/Core/Store/StoreError.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
142
SportsTime/Core/Store/StoreManager.swift
Normal file
142
SportsTime/Core/Store/StoreManager.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user