Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
a94404d93d
commit
7fff295913
Binary file not shown.
@ -11,7 +11,6 @@ import Bedrock
|
|||||||
|
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
@State private var settings = SettingsViewModel()
|
@State private var settings = SettingsViewModel()
|
||||||
@State private var premiumManager = PremiumManager()
|
|
||||||
@State private var showSettings = false
|
@State private var showSettings = false
|
||||||
@State private var showPaywall = false
|
@State private var showPaywall = false
|
||||||
|
|
||||||
@ -112,10 +111,12 @@ struct ContentView: View {
|
|||||||
}) {
|
}) {
|
||||||
SettingsView(viewModel: settings, showPaywall: $showPaywall)
|
SettingsView(viewModel: settings, showPaywall: $showPaywall)
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showPaywall) {
|
.sheet(isPresented: $showPaywall, onDismiss: {
|
||||||
|
// Force refresh of premium status after paywall closes
|
||||||
|
// This ensures SettingsViewModel and UI see the updated premium status
|
||||||
|
settings.refreshPremiumStatus()
|
||||||
|
}) {
|
||||||
PaywallPresenter()
|
PaywallPresenter()
|
||||||
// No callback needed - paywall auto-dismisses on success
|
|
||||||
// and premium status updates automatically via PremiumManager
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -21,9 +21,6 @@ extension SettingsViewModel {
|
|||||||
let wasPremium = premiumManager.isDebugPremiumToggleEnabled
|
let wasPremium = premiumManager.isDebugPremiumToggleEnabled
|
||||||
premiumManager.isDebugPremiumToggleEnabled = newValue
|
premiumManager.isDebugPremiumToggleEnabled = newValue
|
||||||
|
|
||||||
// Update premium status for UI refresh
|
|
||||||
isPremiumUnlocked = premiumManager.isPremiumUnlocked
|
|
||||||
|
|
||||||
// Reset premium settings when toggling OFF
|
// Reset premium settings when toggling OFF
|
||||||
if wasPremium && !newValue {
|
if wasPremium && !newValue {
|
||||||
resetPremiumSettingsToDefaults()
|
resetPremiumSettingsToDefaults()
|
||||||
|
|||||||
@ -76,13 +76,22 @@ final class SettingsViewModel: RingLightConfigurable {
|
|||||||
|
|
||||||
// MARK: - Premium Status
|
// MARK: - Premium Status
|
||||||
|
|
||||||
/// Whether the user has premium access (stored property for proper UI updates)
|
/// Refresh token that changes when premium status needs to be re-evaluated
|
||||||
private var _isPremiumUnlocked: Bool = false
|
/// Incrementing this forces SwiftUI to re-compute isPremiumUnlocked
|
||||||
|
private var premiumRefreshToken: Int = 0
|
||||||
|
|
||||||
/// Whether the user has premium access (observable for UI updates)
|
/// Whether the user has premium access (computed from PremiumManager)
|
||||||
|
/// This always reads the current status from RevenueCat's cached customer info
|
||||||
var isPremiumUnlocked: Bool {
|
var isPremiumUnlocked: Bool {
|
||||||
get { _isPremiumUnlocked }
|
// Access refresh token to create observation dependency
|
||||||
set { _isPremiumUnlocked = newValue }
|
_ = premiumRefreshToken
|
||||||
|
return premiumManager.isPremiumUnlocked
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Force SwiftUI to re-evaluate premium status
|
||||||
|
/// Call this after a purchase completes or when returning from paywall
|
||||||
|
func refreshPremiumStatus() {
|
||||||
|
premiumRefreshToken += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Display Settings
|
// MARK: - Display Settings
|
||||||
@ -128,9 +137,8 @@ final class SettingsViewModel: RingLightConfigurable {
|
|||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
// Initialize premium status
|
|
||||||
_isPremiumUnlocked = premiumManager.isPremiumUnlocked
|
|
||||||
// CloudSyncManager handles syncing automatically
|
// CloudSyncManager handles syncing automatically
|
||||||
|
// Premium status is always computed from premiumManager.isPremiumUnlocked
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Internal Methods
|
// MARK: - Internal Methods
|
||||||
|
|||||||
916
docs/REVENUECAT_INTEGRATION_GUIDE.md
Normal file
916
docs/REVENUECAT_INTEGRATION_GUIDE.md
Normal file
@ -0,0 +1,916 @@
|
|||||||
|
# RevenueCat Integration Guide for iOS
|
||||||
|
|
||||||
|
A comprehensive, reusable guide for integrating RevenueCat into iOS apps with SwiftUI. This guide covers the complete flow from SDK setup to production deployment.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [Overview](#overview)
|
||||||
|
2. [Prerequisites](#prerequisites)
|
||||||
|
3. [SDK Installation](#sdk-installation)
|
||||||
|
4. [Configuration Architecture](#configuration-architecture)
|
||||||
|
5. [PremiumManager Implementation](#premiummanager-implementation)
|
||||||
|
6. [Paywall Implementation](#paywall-implementation)
|
||||||
|
7. [Event Handling](#event-handling)
|
||||||
|
8. [App Store Connect Setup](#app-store-connect-setup)
|
||||||
|
9. [RevenueCat Dashboard Setup](#revenuecat-dashboard-setup)
|
||||||
|
10. [Testing Strategies](#testing-strategies)
|
||||||
|
11. [Production Checklist](#production-checklist)
|
||||||
|
12. [Troubleshooting](#troubleshooting)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
RevenueCat simplifies in-app purchases by:
|
||||||
|
- Handling receipt validation server-side
|
||||||
|
- Providing cross-platform subscription status
|
||||||
|
- Offering analytics (MRR, churn, LTV)
|
||||||
|
- Managing entitlements across multiple products
|
||||||
|
- Providing pre-built UI components (Paywalls, Customer Center)
|
||||||
|
|
||||||
|
### Key Concepts
|
||||||
|
|
||||||
|
| Concept | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| **Product** | An individual purchasable item (monthly, yearly, lifetime) |
|
||||||
|
| **Entitlement** | A feature or access level granted by one or more products |
|
||||||
|
| **Offering** | A collection of packages shown in a paywall |
|
||||||
|
| **Package** | A product wrapped with metadata for display |
|
||||||
|
| **Customer Info** | User's current subscription/purchase state |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Apple Developer Program membership ($99/year)
|
||||||
|
- RevenueCat account (free up to $2,500/month revenue)
|
||||||
|
- Xcode 15.0+
|
||||||
|
- iOS 15.0+ deployment target
|
||||||
|
- Swift 5.9+
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SDK Installation
|
||||||
|
|
||||||
|
### Swift Package Manager (Recommended)
|
||||||
|
|
||||||
|
1. In Xcode: **File** → **Add Package Dependencies**
|
||||||
|
2. Enter: `https://github.com/RevenueCat/purchases-ios-spm`
|
||||||
|
3. Select version (use latest stable)
|
||||||
|
4. Add both packages to your target:
|
||||||
|
- `RevenueCat` - Core SDK
|
||||||
|
- `RevenueCatUI` - Paywalls and Customer Center
|
||||||
|
|
||||||
|
### Package.swift (for packages)
|
||||||
|
|
||||||
|
```swift
|
||||||
|
dependencies: [
|
||||||
|
.package(url: "https://github.com/RevenueCat/purchases-ios-spm", from: "5.0.0")
|
||||||
|
],
|
||||||
|
targets: [
|
||||||
|
.target(
|
||||||
|
name: "YourApp",
|
||||||
|
dependencies: [
|
||||||
|
.product(name: "RevenueCat", package: "purchases-ios-spm"),
|
||||||
|
.product(name: "RevenueCatUI", package: "purchases-ios-spm")
|
||||||
|
]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration Architecture
|
||||||
|
|
||||||
|
### Secrets Management
|
||||||
|
|
||||||
|
Create a secure configuration system that separates API keys from source code.
|
||||||
|
|
||||||
|
#### Option A: Swift Secrets File (Simple)
|
||||||
|
|
||||||
|
Create `Configuration/Secrets.swift`:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
// Secrets.swift
|
||||||
|
// DO NOT COMMIT THIS FILE TO VERSION CONTROL
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum Secrets {
|
||||||
|
/// RevenueCat API Key
|
||||||
|
/// - Debug: Use test/sandbox key (starts with "test_")
|
||||||
|
/// - Release: Use production key (starts with "appl_")
|
||||||
|
static let revenueCatAPIKey = "YOUR_API_KEY_HERE"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Create `Configuration/Secrets.swift.template` (committed):
|
||||||
|
|
||||||
|
```swift
|
||||||
|
// Copy this file to Secrets.swift and add your actual API key
|
||||||
|
// Secrets.swift is gitignored
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum Secrets {
|
||||||
|
static let revenueCatAPIKey = "YOUR_REVENUECAT_API_KEY_HERE"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add to `.gitignore`:
|
||||||
|
|
||||||
|
```
|
||||||
|
**/Secrets.swift
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Option B: Separate Debug/Release Keys (Recommended for Production)
|
||||||
|
|
||||||
|
Create two secrets files:
|
||||||
|
|
||||||
|
**`Secrets.debug.swift`** (for testing):
|
||||||
|
```swift
|
||||||
|
enum Secrets {
|
||||||
|
static let revenueCatAPIKey = "test_your_test_key"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`Secrets.release.swift`** (for production):
|
||||||
|
```swift
|
||||||
|
enum Secrets {
|
||||||
|
static let revenueCatAPIKey = "appl_your_production_key"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use build configurations or compiler flags to include the correct file.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PremiumManager Implementation
|
||||||
|
|
||||||
|
Create a centralized manager for all RevenueCat interactions.
|
||||||
|
|
||||||
|
### Complete Implementation
|
||||||
|
|
||||||
|
```swift
|
||||||
|
import RevenueCat
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class PremiumManager {
|
||||||
|
|
||||||
|
// MARK: - Published State
|
||||||
|
|
||||||
|
/// Available packages for purchase
|
||||||
|
var availablePackages: [Package] = []
|
||||||
|
|
||||||
|
// MARK: - Configuration
|
||||||
|
|
||||||
|
/// Entitlement identifier - must match RevenueCat dashboard
|
||||||
|
private let entitlementIdentifier = "pro"
|
||||||
|
|
||||||
|
/// Task for listening to customer info updates
|
||||||
|
@ObservationIgnored private var customerInfoTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
/// API key from secrets
|
||||||
|
private static var apiKey: String {
|
||||||
|
let key = Secrets.revenueCatAPIKey
|
||||||
|
guard !key.isEmpty, key != "YOUR_REVENUECAT_API_KEY_HERE" else {
|
||||||
|
#if DEBUG
|
||||||
|
print("⚠️ [PremiumManager] RevenueCat API key not configured")
|
||||||
|
#endif
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Debug Override (Optional)
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
@AppStorage("debugPremiumEnabled")
|
||||||
|
@ObservationIgnored private var debugPremiumEnabled = false
|
||||||
|
|
||||||
|
private var isDebugPremiumEnabled: Bool {
|
||||||
|
debugPremiumEnabled || ProcessInfo.processInfo.environment["ENABLE_DEBUG_PREMIUM"] == "1"
|
||||||
|
}
|
||||||
|
|
||||||
|
var isDebugPremiumToggleEnabled: Bool {
|
||||||
|
get { debugPremiumEnabled }
|
||||||
|
set { debugPremiumEnabled = newValue }
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// MARK: - Premium Status
|
||||||
|
|
||||||
|
var isPremium: Bool {
|
||||||
|
#if DEBUG
|
||||||
|
if isDebugPremiumEnabled { return true }
|
||||||
|
#endif
|
||||||
|
|
||||||
|
guard !Self.apiKey.isEmpty else { return false }
|
||||||
|
|
||||||
|
return Purchases.shared.cachedCustomerInfo?
|
||||||
|
.entitlements[entitlementIdentifier]?.isActive == true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Alias for compatibility
|
||||||
|
var isPremiumUnlocked: Bool { isPremium }
|
||||||
|
|
||||||
|
// MARK: - Initialization
|
||||||
|
|
||||||
|
init() {
|
||||||
|
guard !Self.apiKey.isEmpty else {
|
||||||
|
#if DEBUG
|
||||||
|
print("⚠️ [PremiumManager] Skipping RevenueCat configuration - no API key")
|
||||||
|
#endif
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
Purchases.logLevel = .debug
|
||||||
|
#endif
|
||||||
|
|
||||||
|
Purchases.configure(withAPIKey: Self.apiKey)
|
||||||
|
|
||||||
|
Task {
|
||||||
|
try? await loadProducts()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Products
|
||||||
|
|
||||||
|
func loadProducts() async throws {
|
||||||
|
guard !Self.apiKey.isEmpty else { return }
|
||||||
|
|
||||||
|
let offerings = try await Purchases.shared.offerings()
|
||||||
|
if let current = offerings.current {
|
||||||
|
availablePackages = current.availablePackages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Purchase
|
||||||
|
|
||||||
|
func purchase(_ package: Package) async throws -> Bool {
|
||||||
|
#if DEBUG
|
||||||
|
if isDebugPremiumEnabled {
|
||||||
|
return true // Simulate success
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
let result = try await Purchases.shared.purchase(package: package)
|
||||||
|
return result.customerInfo.entitlements[entitlementIdentifier]?.isActive == true
|
||||||
|
}
|
||||||
|
|
||||||
|
func purchase(productId: String) async throws {
|
||||||
|
guard let package = availablePackages.first(where: {
|
||||||
|
$0.storeProduct.productIdentifier == productId
|
||||||
|
}) else {
|
||||||
|
throw PurchaseError.productNotFound
|
||||||
|
}
|
||||||
|
_ = try await purchase(package)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Restore
|
||||||
|
|
||||||
|
func restorePurchases() async throws {
|
||||||
|
#if DEBUG
|
||||||
|
if isDebugPremiumEnabled { return }
|
||||||
|
#endif
|
||||||
|
|
||||||
|
_ = try await Purchases.shared.restorePurchases()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Subscription Status
|
||||||
|
|
||||||
|
/// Fetch fresh customer info (call on app launch)
|
||||||
|
func checkSubscriptionStatus() async {
|
||||||
|
guard !Self.apiKey.isEmpty else { return }
|
||||||
|
|
||||||
|
do {
|
||||||
|
_ = try await Purchases.shared.customerInfo()
|
||||||
|
} catch {
|
||||||
|
#if DEBUG
|
||||||
|
print("⚠️ [PremiumManager] Failed to fetch customer info: \(error)")
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Real-time Updates
|
||||||
|
|
||||||
|
/// Start listening for subscription changes
|
||||||
|
func startListeningForCustomerInfoUpdates() {
|
||||||
|
guard !Self.apiKey.isEmpty else { return }
|
||||||
|
|
||||||
|
customerInfoTask?.cancel()
|
||||||
|
|
||||||
|
customerInfoTask = Task {
|
||||||
|
for await customerInfo in Purchases.shared.customerInfoStream {
|
||||||
|
let isActive = customerInfo.entitlements[entitlementIdentifier]?.isActive == true
|
||||||
|
#if DEBUG
|
||||||
|
print("📱 [PremiumManager] Customer info updated. Premium: \(isActive)")
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopListeningForCustomerInfoUpdates() {
|
||||||
|
customerInfoTask?.cancel()
|
||||||
|
customerInfoTask = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Errors
|
||||||
|
|
||||||
|
enum PurchaseError: LocalizedError {
|
||||||
|
case productNotFound
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .productNotFound:
|
||||||
|
return "Product not found"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### App Entry Point Integration
|
||||||
|
|
||||||
|
```swift
|
||||||
|
@main
|
||||||
|
struct YourApp: App {
|
||||||
|
@State private var premiumManager = PremiumManager()
|
||||||
|
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
ContentView()
|
||||||
|
.environment(premiumManager)
|
||||||
|
.task {
|
||||||
|
await premiumManager.checkSubscriptionStatus()
|
||||||
|
premiumManager.startListeningForCustomerInfoUpdates()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Paywall Implementation
|
||||||
|
|
||||||
|
### PaywallPresenter with Fallback
|
||||||
|
|
||||||
|
Use RevenueCat's native PaywallView with a custom fallback for offline scenarios.
|
||||||
|
|
||||||
|
```swift
|
||||||
|
import SwiftUI
|
||||||
|
import RevenueCat
|
||||||
|
import RevenueCatUI
|
||||||
|
|
||||||
|
struct PaywallPresenter: View {
|
||||||
|
/// Called on successful purchase or restore
|
||||||
|
var onPurchaseSuccess: (() -> Void)?
|
||||||
|
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var offering: Offering?
|
||||||
|
@State private var isLoading = true
|
||||||
|
@State private var useFallback = false
|
||||||
|
@State private var errorMessage: String?
|
||||||
|
@State private var showError = false
|
||||||
|
|
||||||
|
init(onPurchaseSuccess: (() -> Void)? = nil) {
|
||||||
|
self.onPurchaseSuccess = onPurchaseSuccess
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if isLoading {
|
||||||
|
loadingView
|
||||||
|
} else if useFallback {
|
||||||
|
CustomPaywallView(onPurchaseSuccess: onPurchaseSuccess)
|
||||||
|
} else if let offering {
|
||||||
|
paywallView(for: offering)
|
||||||
|
} else {
|
||||||
|
CustomPaywallView(onPurchaseSuccess: onPurchaseSuccess)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
await loadOffering()
|
||||||
|
}
|
||||||
|
.alert("Purchase Error", isPresented: $showError, presenting: errorMessage) { _ in
|
||||||
|
Button("OK", role: .cancel) { errorMessage = nil }
|
||||||
|
} message: { message in
|
||||||
|
Text(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var loadingView: some View {
|
||||||
|
VStack {
|
||||||
|
ProgressView()
|
||||||
|
.scaleEffect(1.5)
|
||||||
|
Text("Loading...")
|
||||||
|
.padding(.top, 16)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func paywallView(for offering: Offering) -> some View {
|
||||||
|
PaywallView(offering: offering)
|
||||||
|
.onPurchaseCompleted { _ in
|
||||||
|
onPurchaseSuccess?()
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.onPurchaseCancelled {
|
||||||
|
// User cancelled - keep paywall open
|
||||||
|
}
|
||||||
|
.onPurchaseFailure { error in
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
showError = true
|
||||||
|
}
|
||||||
|
.onRestoreCompleted { customerInfo in
|
||||||
|
if !customerInfo.entitlements.active.isEmpty {
|
||||||
|
onPurchaseSuccess?()
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onRestoreFailure { error in
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
showError = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadOffering() async {
|
||||||
|
do {
|
||||||
|
let offerings = try await Purchases.shared.offerings()
|
||||||
|
if let current = offerings.current {
|
||||||
|
offering = current
|
||||||
|
} else {
|
||||||
|
useFallback = true
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
useFallback = true
|
||||||
|
}
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Paywall Integration Patterns
|
||||||
|
|
||||||
|
There are two primary contexts for presenting paywalls, each requiring different handling.
|
||||||
|
|
||||||
|
#### Use Case 1: Onboarding Flow
|
||||||
|
|
||||||
|
During onboarding, a successful purchase should complete the onboarding and transition to the main app.
|
||||||
|
|
||||||
|
```swift
|
||||||
|
struct RootView: View {
|
||||||
|
@AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false
|
||||||
|
@State private var onboardingViewModel = OnboardingViewModel()
|
||||||
|
@State private var settingsViewModel = SettingsViewModel()
|
||||||
|
@State private var showPaywall = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
if hasCompletedOnboarding {
|
||||||
|
MainAppView()
|
||||||
|
} else {
|
||||||
|
OnboardingView(
|
||||||
|
viewModel: onboardingViewModel,
|
||||||
|
showPaywall: $showPaywall,
|
||||||
|
onComplete: {
|
||||||
|
withAnimation {
|
||||||
|
hasCompletedOnboarding = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showPaywall) {
|
||||||
|
PaywallPresenter {
|
||||||
|
// Purchase successful during onboarding - complete it
|
||||||
|
if !hasCompletedOnboarding {
|
||||||
|
onboardingViewModel.completeOnboarding(settings: settingsViewModel)
|
||||||
|
withAnimation {
|
||||||
|
hasCompletedOnboarding = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key points for onboarding:**
|
||||||
|
- Pass a success callback that completes onboarding
|
||||||
|
- Transition directly to main app after successful purchase
|
||||||
|
- User skipping the paywall continues onboarding normally
|
||||||
|
|
||||||
|
#### Use Case 2: In-App Purchase (Settings, Feature Gates)
|
||||||
|
|
||||||
|
When users trigger the paywall from within the app (e.g., tapping a premium feature in Settings), the UI must refresh to reflect the new premium status after purchase.
|
||||||
|
|
||||||
|
```swift
|
||||||
|
struct MainContentView: View {
|
||||||
|
@State private var settings = SettingsViewModel()
|
||||||
|
@State private var showSettings = false
|
||||||
|
@State private var showPaywall = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
// Main app content
|
||||||
|
YourMainView()
|
||||||
|
.sheet(isPresented: $showSettings) {
|
||||||
|
SettingsView(viewModel: settings, showPaywall: $showPaywall)
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showPaywall, onDismiss: {
|
||||||
|
// CRITICAL: Force UI to refresh premium status after paywall closes
|
||||||
|
settings.refreshPremiumStatus()
|
||||||
|
}) {
|
||||||
|
PaywallPresenter()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key points for in-app purchases:**
|
||||||
|
- Use `onDismiss` to trigger a premium status refresh
|
||||||
|
- No success callback needed (UI refreshes via observation)
|
||||||
|
- Settings dismisses before showing paywall, then user returns to main view
|
||||||
|
|
||||||
|
#### Combined Pattern
|
||||||
|
|
||||||
|
For apps with both onboarding and in-app paywalls, handle both cases:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
.sheet(isPresented: $showPaywall, onDismiss: {
|
||||||
|
// Always refresh premium status when paywall closes
|
||||||
|
settings.refreshPremiumStatus()
|
||||||
|
}) {
|
||||||
|
PaywallPresenter {
|
||||||
|
// Only used during onboarding
|
||||||
|
if !hasCompletedOnboarding {
|
||||||
|
completeOnboarding()
|
||||||
|
}
|
||||||
|
// For in-app purchases, the onDismiss handles the refresh
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Event Handling
|
||||||
|
|
||||||
|
### Available RevenueCat PaywallView Callbacks
|
||||||
|
|
||||||
|
| Modifier | When Called |
|
||||||
|
|----------|------------|
|
||||||
|
| `.onPurchaseStarted { package in }` | Purchase initiated for a package |
|
||||||
|
| `.onPurchaseCompleted { customerInfo in }` | Purchase successful |
|
||||||
|
| `.onPurchaseCancelled { }` | User cancelled purchase |
|
||||||
|
| `.onPurchaseFailure { error in }` | Purchase failed |
|
||||||
|
| `.onRestoreStarted { }` | Restore initiated |
|
||||||
|
| `.onRestoreCompleted { customerInfo in }` | Restore successful |
|
||||||
|
| `.onRestoreFailure { error in }` | Restore failed |
|
||||||
|
| `.onRequestedDismissal { }` | Paywall requests to close |
|
||||||
|
|
||||||
|
### Error Handling Best Practices
|
||||||
|
|
||||||
|
```swift
|
||||||
|
.onPurchaseFailure { error in
|
||||||
|
let nsError = error as NSError
|
||||||
|
|
||||||
|
// Don't show error for user cancellation
|
||||||
|
if nsError.code == 2 { // SKError.paymentCancelled
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle specific error types
|
||||||
|
switch nsError.code {
|
||||||
|
case 0: // SKError.unknown
|
||||||
|
errorMessage = "An unknown error occurred. Please try again."
|
||||||
|
case 1: // SKError.clientInvalid
|
||||||
|
errorMessage = "You are not authorized to make purchases."
|
||||||
|
case 3: // SKError.paymentNotAllowed
|
||||||
|
errorMessage = "Payments are not allowed on this device."
|
||||||
|
default:
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
showError = true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SwiftUI Observation and Premium Status Updates
|
||||||
|
|
||||||
|
### The Problem
|
||||||
|
|
||||||
|
When a user completes a purchase, RevenueCat updates `Purchases.shared.cachedCustomerInfo` with the new subscription status. However, SwiftUI's `@Observable` macro doesn't automatically detect changes to this shared singleton.
|
||||||
|
|
||||||
|
This causes a common bug: **after purchasing from a paywall triggered outside of onboarding (e.g., from Settings), the UI doesn't reflect the new premium status** even though the purchase succeeded.
|
||||||
|
|
||||||
|
### Why It Happens
|
||||||
|
|
||||||
|
1. Your `SettingsViewModel` (or similar) has a `PremiumManager` instance
|
||||||
|
2. `isPremiumUnlocked` is computed from `premiumManager.isPremiumUnlocked`
|
||||||
|
3. `PremiumManager.isPremiumUnlocked` reads from `Purchases.shared.cachedCustomerInfo`
|
||||||
|
4. After purchase, the cached customer info IS updated
|
||||||
|
5. But SwiftUI doesn't know to re-render because no `@Observable` property changed
|
||||||
|
|
||||||
|
### Solution: Refresh Token Pattern
|
||||||
|
|
||||||
|
Add a refresh mechanism that forces SwiftUI to re-evaluate the premium status:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class SettingsViewModel {
|
||||||
|
@ObservationIgnored let premiumManager = PremiumManager()
|
||||||
|
|
||||||
|
// Refresh token that forces re-evaluation when incremented
|
||||||
|
private var premiumRefreshToken: Int = 0
|
||||||
|
|
||||||
|
/// Premium status - always reads current value from RevenueCat
|
||||||
|
var isPremiumUnlocked: Bool {
|
||||||
|
// Access refresh token to create observation dependency
|
||||||
|
_ = premiumRefreshToken
|
||||||
|
return premiumManager.isPremiumUnlocked
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Force SwiftUI to re-evaluate premium status
|
||||||
|
/// Call this after paywall dismisses
|
||||||
|
func refreshPremiumStatus() {
|
||||||
|
premiumRefreshToken += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Triggering the Refresh
|
||||||
|
|
||||||
|
Call `refreshPremiumStatus()` when the paywall sheet dismisses:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
struct ContentView: View {
|
||||||
|
@State private var settings = SettingsViewModel()
|
||||||
|
@State private var showPaywall = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
// ... your content ...
|
||||||
|
.sheet(isPresented: $showPaywall, onDismiss: {
|
||||||
|
// Force UI to re-check premium status after paywall closes
|
||||||
|
settings.refreshPremiumStatus()
|
||||||
|
}) {
|
||||||
|
PaywallPresenter()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Alternative: Shared PremiumManager
|
||||||
|
|
||||||
|
Instead of each view model having its own `PremiumManager`, you can use a single shared instance passed through the environment:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
@main
|
||||||
|
struct YourApp: App {
|
||||||
|
@State private var premiumManager = PremiumManager()
|
||||||
|
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
ContentView()
|
||||||
|
.environment(premiumManager)
|
||||||
|
.task {
|
||||||
|
await premiumManager.checkSubscriptionStatus()
|
||||||
|
premiumManager.startListeningForCustomerInfoUpdates()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// In views:
|
||||||
|
struct SettingsView: View {
|
||||||
|
@Environment(PremiumManager.self) private var premiumManager
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if premiumManager.isPremiumUnlocked {
|
||||||
|
// Premium content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This approach ensures all views observe the same instance and receive updates together.
|
||||||
|
|
||||||
|
### Key Takeaways
|
||||||
|
|
||||||
|
1. **Always refresh after paywall dismisses** - Use `onDismiss` on the sheet
|
||||||
|
2. **Use a refresh token** for computed properties that read from external sources
|
||||||
|
3. **Consider a shared instance** if premium status is checked in many places
|
||||||
|
4. **Test the full flow** - Skip onboarding, trigger paywall from Settings, complete purchase, verify UI updates
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## App Store Connect Setup
|
||||||
|
|
||||||
|
### 1. Create Your App
|
||||||
|
|
||||||
|
1. Go to [App Store Connect](https://appstoreconnect.apple.com)
|
||||||
|
2. **My Apps** → **+** → **New App**
|
||||||
|
3. Fill in bundle ID (must match Xcode exactly)
|
||||||
|
|
||||||
|
### 2. Create Subscription Group
|
||||||
|
|
||||||
|
1. In your app → **Subscriptions**
|
||||||
|
2. Click **+** next to "Subscription Groups"
|
||||||
|
3. Name it (e.g., "Pro Access")
|
||||||
|
|
||||||
|
### 3. Create Products
|
||||||
|
|
||||||
|
**Auto-Renewable Subscriptions** (monthly, yearly):
|
||||||
|
|
||||||
|
| Field | Example Value |
|
||||||
|
|-------|--------------|
|
||||||
|
| Reference Name | Pro Monthly |
|
||||||
|
| Product ID | `com.company.app.pro.monthly` |
|
||||||
|
| Duration | 1 Month |
|
||||||
|
| Price | $2.99 |
|
||||||
|
|
||||||
|
**Non-Consumable** (lifetime):
|
||||||
|
|
||||||
|
| Field | Example Value |
|
||||||
|
|-------|--------------|
|
||||||
|
| Reference Name | Pro Lifetime |
|
||||||
|
| Product ID | `com.company.app.pro.lifetime` |
|
||||||
|
| Price | $39.99 |
|
||||||
|
|
||||||
|
### 4. Agreements
|
||||||
|
|
||||||
|
1. **Agreements, Tax, and Banking** → Accept **Paid Applications**
|
||||||
|
2. Add bank account and tax information
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## RevenueCat Dashboard Setup
|
||||||
|
|
||||||
|
### 1. Create Project and App
|
||||||
|
|
||||||
|
1. Create project at [RevenueCat](https://app.revenuecat.com)
|
||||||
|
2. Add iOS app with your bundle ID
|
||||||
|
|
||||||
|
### 2. Connect to App Store
|
||||||
|
|
||||||
|
1. Get App-Specific Shared Secret from App Store Connect
|
||||||
|
2. Add to RevenueCat: **Project Settings** → **Apps** → your app
|
||||||
|
|
||||||
|
### 3. Create Products
|
||||||
|
|
||||||
|
Add each product with IDs matching App Store Connect exactly.
|
||||||
|
|
||||||
|
### 4. Create Entitlement
|
||||||
|
|
||||||
|
1. **Entitlements** → **+ New**
|
||||||
|
2. Name: `pro` (or your identifier)
|
||||||
|
3. Attach all products that grant this entitlement
|
||||||
|
|
||||||
|
### 5. Create Offering
|
||||||
|
|
||||||
|
1. **Offerings** → Edit **default**
|
||||||
|
2. Add packages:
|
||||||
|
- `$rc_monthly` → monthly product
|
||||||
|
- `$rc_annual` → yearly product
|
||||||
|
- `$rc_lifetime` → lifetime product
|
||||||
|
|
||||||
|
### 6. Design Paywall (Optional)
|
||||||
|
|
||||||
|
1. In your offering, click **Add Paywall**
|
||||||
|
2. Use the visual editor to design
|
||||||
|
3. Configure colors, text, layout
|
||||||
|
|
||||||
|
### 7. Get API Keys
|
||||||
|
|
||||||
|
- **Test Key** (starts with `test_`): For development, free purchases
|
||||||
|
- **Production Key** (starts with `appl_`): For App Store builds
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Strategies
|
||||||
|
|
||||||
|
### 1. Debug Premium Toggle (Fastest)
|
||||||
|
|
||||||
|
For UI testing without purchase flow:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
#if DEBUG
|
||||||
|
Toggle("Debug Premium", isOn: $premiumManager.isDebugPremiumToggleEnabled)
|
||||||
|
#endif
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. StoreKit Configuration File (Local)
|
||||||
|
|
||||||
|
1. **File** → **New** → **StoreKit Configuration File**
|
||||||
|
2. Add products matching your IDs
|
||||||
|
3. **Edit Scheme** → **Run** → **Options** → Set StoreKit Configuration
|
||||||
|
4. Purchases are instant and free
|
||||||
|
|
||||||
|
### 3. RevenueCat Test Mode (Integration)
|
||||||
|
|
||||||
|
Use the test API key (`test_`):
|
||||||
|
- Purchases are free
|
||||||
|
- Transactions appear in RevenueCat dashboard
|
||||||
|
- Restore not available (returns cached info)
|
||||||
|
|
||||||
|
### 4. Sandbox Testing (Full)
|
||||||
|
|
||||||
|
1. Create Sandbox Tester in App Store Connect
|
||||||
|
2. Sign out of App Store on device
|
||||||
|
3. Make purchase, sign in with sandbox credentials
|
||||||
|
4. Verify in RevenueCat dashboard
|
||||||
|
|
||||||
|
**Sandbox Renewal Times:**
|
||||||
|
|
||||||
|
| Real | Sandbox |
|
||||||
|
|------|---------|
|
||||||
|
| 1 week | 3 min |
|
||||||
|
| 1 month | 5 min |
|
||||||
|
| 1 year | 1 hour |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Production Checklist
|
||||||
|
|
||||||
|
### Code
|
||||||
|
|
||||||
|
- [ ] PremiumManager configured with production API key
|
||||||
|
- [ ] Secrets file is gitignored
|
||||||
|
- [ ] Error handling for all purchase scenarios
|
||||||
|
- [ ] Loading states during purchases
|
||||||
|
- [ ] Restore purchases functionality
|
||||||
|
- [ ] Real-time subscription status updates
|
||||||
|
|
||||||
|
### App Store Connect
|
||||||
|
|
||||||
|
- [ ] App created with correct bundle ID
|
||||||
|
- [ ] All products created with localizations
|
||||||
|
- [ ] Subscription group configured
|
||||||
|
- [ ] Paid Apps agreement accepted
|
||||||
|
- [ ] Bank and tax info submitted
|
||||||
|
|
||||||
|
### RevenueCat Dashboard
|
||||||
|
|
||||||
|
- [ ] Production app created with bundle ID
|
||||||
|
- [ ] Shared secret configured
|
||||||
|
- [ ] All products added (IDs match exactly)
|
||||||
|
- [ ] Entitlement created with products attached
|
||||||
|
- [ ] Offering configured with packages
|
||||||
|
- [ ] (Optional) Paywall designed
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- [ ] Tested with StoreKit Configuration
|
||||||
|
- [ ] Tested with sandbox account
|
||||||
|
- [ ] Verified transactions in RevenueCat dashboard
|
||||||
|
- [ ] Tested restore purchases
|
||||||
|
- [ ] Tested subscription expiration/renewal
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Products Not Loading
|
||||||
|
|
||||||
|
1. Verify product IDs match exactly (case-sensitive)
|
||||||
|
2. Check API key is correct
|
||||||
|
3. Ensure shared secret is configured
|
||||||
|
4. Wait a few minutes after creating products
|
||||||
|
|
||||||
|
### "Purchases has not been configured"
|
||||||
|
|
||||||
|
- `Purchases.configure(withAPIKey:)` must be called before any other SDK methods
|
||||||
|
- Ensure it's called early in app lifecycle (App init or `@main`)
|
||||||
|
|
||||||
|
### Sandbox Purchase Fails
|
||||||
|
|
||||||
|
1. Sign out of regular App Store
|
||||||
|
2. Use sandbox credentials (not real Apple ID)
|
||||||
|
3. Check Paid Apps agreement is accepted
|
||||||
|
4. Ensure products have prices set
|
||||||
|
|
||||||
|
### Entitlements Not Granting Access
|
||||||
|
|
||||||
|
1. Verify entitlement identifier matches code exactly
|
||||||
|
2. Check products are attached to entitlement
|
||||||
|
3. Confirm packages are in the offering
|
||||||
|
|
||||||
|
### RevenueCat Dashboard Empty
|
||||||
|
|
||||||
|
1. Using test key shows in sandbox environment
|
||||||
|
2. Check bundle ID matches exactly
|
||||||
|
3. Wait 1-2 minutes for transactions to appear
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
- [RevenueCat Documentation](https://docs.revenuecat.com)
|
||||||
|
- [RevenueCat iOS SDK Reference](https://sdk.revenuecat.com/ios/index.html)
|
||||||
|
- [App Store Connect Help](https://help.apple.com/app-store-connect/)
|
||||||
|
- [StoreKit Documentation](https://developer.apple.com/documentation/storekit)
|
||||||
Loading…
Reference in New Issue
Block a user