From d62cc6e88ef129507903c5c3cc9f311c9a59572f Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Tue, 3 Feb 2026 15:57:01 -0600 Subject: [PATCH] Signed-off-by: Matt Bruce --- docs/REVENUECAT_INTEGRATION_GUIDE.md | 108 +++++++++++++++++++++------ 1 file changed, 86 insertions(+), 22 deletions(-) diff --git a/docs/REVENUECAT_INTEGRATION_GUIDE.md b/docs/REVENUECAT_INTEGRATION_GUIDE.md index 6af05f1..ae6aeec 100644 --- a/docs/REVENUECAT_INTEGRATION_GUIDE.md +++ b/docs/REVENUECAT_INTEGRATION_GUIDE.md @@ -606,15 +606,25 @@ For apps with both onboarding and in-app paywalls, handle both cases: 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. +**This affects the in-app purchase flow (Use Case 2), not onboarding (Use Case 1):** -### Why It Happens +| Use Case | Affected? | Why | +|----------|-----------|-----| +| Onboarding | No | Success callback explicitly completes onboarding and transitions to main app | +| In-App (Settings) | **Yes** | UI must detect the change to show unlocked features | -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 +### Why In-App Purchases Don't Update the UI + +1. User opens Settings (which has a `SettingsViewModel` with its own `PremiumManager`) +2. User taps a premium feature, triggering the paywall +3. User completes purchase successfully +4. Paywall dismisses, returning to Settings +5. `isPremiumUnlocked` still returns `false` in the UI even though purchase succeeded + +**Technical reason:** +- `isPremiumUnlocked` reads from `Purchases.shared.cachedCustomerInfo` +- The cached customer info IS updated after purchase +- But SwiftUI doesn't re-render because no `@Observable` property changed ### Solution: Refresh Token Pattern @@ -646,28 +656,65 @@ final class SettingsViewModel { ### Triggering the Refresh -Call `refreshPremiumStatus()` when the paywall sheet dismisses: +Always call `refreshPremiumStatus()` when the paywall sheet dismisses: ```swift -struct ContentView: View { +.sheet(isPresented: $showPaywall, onDismiss: { + // Force UI to re-check premium status after paywall closes + // This handles both successful purchases and user cancellations + settings.refreshPremiumStatus() +}) { + PaywallPresenter() +} +``` + +**Why use `onDismiss` instead of the success callback?** +- `onDismiss` is called for all dismissal reasons (purchase, cancel, swipe down) +- It ensures the UI always reflects the current state +- Works for both onboarding and in-app contexts + +### Complete Integration Example + +Handling both onboarding and in-app purchase flows: + +```swift +struct RootView: View { + @AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false @State private var settings = SettingsViewModel() @State private var showPaywall = false var body: some View { - // ... your content ... + Group { + if hasCompletedOnboarding { + MainAppView(settings: settings, showPaywall: $showPaywall) + } else { + OnboardingView(showPaywall: $showPaywall, onComplete: completeOnboarding) + } + } .sheet(isPresented: $showPaywall, onDismiss: { - // Force UI to re-check premium status after paywall closes + // ALWAYS refresh after paywall closes (for in-app purchases) settings.refreshPremiumStatus() }) { - PaywallPresenter() + PaywallPresenter { + // Only for onboarding: complete it on successful purchase + if !hasCompletedOnboarding { + completeOnboarding() + } + } + } + } + + private func completeOnboarding() { + withAnimation { + hasCompletedOnboarding = true } } } ``` -### Alternative: Shared PremiumManager +### Alternative: Shared PremiumManager via Environment -Instead of each view model having its own `PremiumManager`, you can use a single shared instance passed through the environment: +Instead of each view model having its own `PremiumManager`, use a single shared instance: ```swift @main @@ -676,7 +723,7 @@ struct YourApp: App { var body: some Scene { WindowGroup { - ContentView() + RootView() .environment(premiumManager) .task { await premiumManager.checkSubscriptionStatus() @@ -686,7 +733,7 @@ struct YourApp: App { } } -// In views: +// In any view: struct SettingsView: View { @Environment(PremiumManager.self) private var premiumManager @@ -698,14 +745,31 @@ struct SettingsView: View { } ``` -This approach ensures all views observe the same instance and receive updates together. +This approach ensures all views observe the same instance. -### Key Takeaways +### Testing Checklist -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 +Test both purchase flows to ensure they work correctly: + +**Onboarding Flow:** +- [ ] Skip to paywall step in onboarding +- [ ] Complete a purchase +- [ ] Verify: App transitions to main content immediately +- [ ] Verify: Premium features are unlocked + +**In-App Purchase Flow:** +- [ ] Complete onboarding WITHOUT purchasing (tap "Maybe Later") +- [ ] Open Settings +- [ ] Tap a premium feature to trigger paywall +- [ ] Complete a purchase +- [ ] Verify: Paywall dismisses +- [ ] Verify: Settings shows premium features as unlocked +- [ ] Verify: No need to restart app or reopen Settings + +**Restore Purchases:** +- [ ] Test restore from onboarding soft paywall +- [ ] Test restore from in-app paywall +- [ ] Verify: UI updates correctly after restore ---