Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2026-02-03 15:57:01 -06:00
parent 7fff295913
commit d62cc6e88e

View File

@ -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. 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 ### Why In-App Purchases Don't Update the UI
2. `isPremiumUnlocked` is computed from `premiumManager.isPremiumUnlocked`
3. `PremiumManager.isPremiumUnlocked` reads from `Purchases.shared.cachedCustomerInfo` 1. User opens Settings (which has a `SettingsViewModel` with its own `PremiumManager`)
4. After purchase, the cached customer info IS updated 2. User taps a premium feature, triggering the paywall
5. But SwiftUI doesn't know to re-render because no `@Observable` property changed 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 ### Solution: Refresh Token Pattern
@ -646,28 +656,65 @@ final class SettingsViewModel {
### Triggering the Refresh ### Triggering the Refresh
Call `refreshPremiumStatus()` when the paywall sheet dismisses: Always call `refreshPremiumStatus()` when the paywall sheet dismisses:
```swift ```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 settings = SettingsViewModel()
@State private var showPaywall = false @State private var showPaywall = false
var body: some View { var body: some View {
// ... your content ... Group {
if hasCompletedOnboarding {
MainAppView(settings: settings, showPaywall: $showPaywall)
} else {
OnboardingView(showPaywall: $showPaywall, onComplete: completeOnboarding)
}
}
.sheet(isPresented: $showPaywall, onDismiss: { .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() 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 ```swift
@main @main
@ -676,7 +723,7 @@ struct YourApp: App {
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
ContentView() RootView()
.environment(premiumManager) .environment(premiumManager)
.task { .task {
await premiumManager.checkSubscriptionStatus() await premiumManager.checkSubscriptionStatus()
@ -686,7 +733,7 @@ struct YourApp: App {
} }
} }
// In views: // In any view:
struct SettingsView: View { struct SettingsView: View {
@Environment(PremiumManager.self) private var premiumManager @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 Test both purchase flows to ensure they work correctly:
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 **Onboarding Flow:**
4. **Test the full flow** - Skip onboarding, trigger paywall from Settings, complete purchase, verify UI updates - [ ] 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
--- ---