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 {
|
||||
@State private var settings = SettingsViewModel()
|
||||
@State private var premiumManager = PremiumManager()
|
||||
@State private var showSettings = false
|
||||
@State private var showPaywall = false
|
||||
|
||||
@ -112,10 +111,12 @@ struct ContentView: View {
|
||||
}) {
|
||||
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()
|
||||
// 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
|
||||
premiumManager.isDebugPremiumToggleEnabled = newValue
|
||||
|
||||
// Update premium status for UI refresh
|
||||
isPremiumUnlocked = premiumManager.isPremiumUnlocked
|
||||
|
||||
// Reset premium settings when toggling OFF
|
||||
if wasPremium && !newValue {
|
||||
resetPremiumSettingsToDefaults()
|
||||
|
||||
@ -76,13 +76,22 @@ final class SettingsViewModel: RingLightConfigurable {
|
||||
|
||||
// MARK: - Premium Status
|
||||
|
||||
/// Whether the user has premium access (stored property for proper UI updates)
|
||||
private var _isPremiumUnlocked: Bool = false
|
||||
/// Refresh token that changes when premium status needs to be re-evaluated
|
||||
/// 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 {
|
||||
get { _isPremiumUnlocked }
|
||||
set { _isPremiumUnlocked = newValue }
|
||||
// Access refresh token to create observation dependency
|
||||
_ = 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
|
||||
@ -128,9 +137,8 @@ final class SettingsViewModel: RingLightConfigurable {
|
||||
// MARK: - Initialization
|
||||
|
||||
init() {
|
||||
// Initialize premium status
|
||||
_isPremiumUnlocked = premiumManager.isPremiumUnlocked
|
||||
// CloudSyncManager handles syncing automatically
|
||||
// Premium status is always computed from premiumManager.isPremiumUnlocked
|
||||
}
|
||||
|
||||
// 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