Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
7fff295913
commit
d62cc6e88e
@ -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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user