Browse Source

Harden StoreKit paywall edge cases for production readiness.

Cache premium status on launch, gate free-trial UI by intro-offer eligibility, optimistically unlock after purchase, and align Settings subscription actions with the paywall.

Co-authored-by: Cursor <cursoragent@cursor.com>
Hussain Afzal 4 weeks ago
parent
commit
714aaefd62

+ 55 - 15
gramora/Managers/SubscriptionManager.swift

@@ -38,6 +38,8 @@ final class SubscriptionManager: ObservableObject {
         case timeout
     }
 
+    private static let cachedPremiumAccessKey = "subscription.cachedPremiumAccess"
+
     @Published private(set) var productsByID: [String: Product] = [:]
     @Published private(set) var isLoadingProducts = false
     @Published private(set) var purchasingPlan: PaywallPlan?
@@ -46,6 +48,7 @@ final class SubscriptionManager: ObservableObject {
     @Published private(set) var hasResolvedPremiumStatus = false
     @Published private(set) var activePlan: PaywallPlan?
     @Published private(set) var activeRecurringPlan: PaywallPlan?
+    @Published private(set) var introOfferEligibleByPlan: [PaywallPlan: Bool] = [:]
     @Published var purchaseError: PurchaseError?
     @Published var subscriptionSuccessMessage: String?
     @Published var showsCancelSubscriptionReminder = false
@@ -59,6 +62,7 @@ final class SubscriptionManager: ObservableObject {
     private var cancellables = Set<AnyCancellable>()
 
     init() {
+        hasPremiumAccess = UserDefaults.standard.bool(forKey: Self.cachedPremiumAccessKey)
         Task { await listenForTransactionUpdates() }
         Task { await refreshPremiumAccess() }
         Task { await loadProducts(presentLoadingUI: false) }
@@ -114,11 +118,7 @@ final class SubscriptionManager: ObservableObject {
     }
 
     func tagText(for plan: PaywallPlan) -> String? {
-        guard let product = product(for: plan) else { return nil }
-
-        if let subscription = product.subscription,
-           let intro = subscription.introductoryOffer,
-           intro.paymentMode == .freeTrial {
+        if hasFreeTrialOffer(for: plan) {
             return "Free Trial"
         }
 
@@ -135,10 +135,7 @@ final class SubscriptionManager: ObservableObject {
     }
 
     func ctaTitle(for plan: PaywallPlan) -> String {
-        if let product = product(for: plan),
-           let subscription = product.subscription,
-           let intro = subscription.introductoryOffer,
-           intro.paymentMode == .freeTrial {
+        if hasFreeTrialOffer(for: plan) {
             return "START FOR FREE"
         }
 
@@ -155,9 +152,9 @@ final class SubscriptionManager: ObservableObject {
             return "Loading pricing…"
         }
 
-        if let subscription = product.subscription,
-           let intro = subscription.introductoryOffer,
-           intro.paymentMode == .freeTrial {
+        if hasFreeTrialOffer(for: plan),
+           let subscription = product.subscription,
+           let intro = subscription.introductoryOffer {
             let trialPeriod = formattedPeriod(intro.period)
             let billingPeriod = formattedSubscriptionPeriod(subscription.subscriptionPeriod)
             return "\(trialPeriod) Free Trial, then \(product.displayPrice) per \(billingPeriod)"
@@ -192,6 +189,15 @@ final class SubscriptionManager: ObservableObject {
         return "Save \(rounded)%"
     }
 
+    func hasFreeTrialOffer(for plan: PaywallPlan) -> Bool {
+        guard introOfferEligibleByPlan[plan] == true else { return false }
+        guard let product = product(for: plan),
+              let subscription = product.subscription,
+              let intro = subscription.introductoryOffer,
+              intro.paymentMode == .freeTrial else { return false }
+        return true
+    }
+
     func purchase(_ plan: PaywallPlan) async -> Bool {
         purchaseError = nil
 
@@ -245,6 +251,7 @@ final class SubscriptionManager: ObservableObject {
                     return false
                 }
                 await transaction.finish()
+                applyOptimisticPremiumAccess(for: transaction)
                 await refreshPremiumAccessWithRetry()
                 if hasPremiumAccess {
                     NotificationCenter.default.post(name: .subscriptionPurchased, object: nil)
@@ -318,6 +325,7 @@ final class SubscriptionManager: ObservableObject {
 
         if !missingAny, presentLoadingUI != true {
             purchaseError = nil
+            await refreshIntroOfferEligibility()
             await refreshPremiumAccess()
             return
         }
@@ -339,6 +347,7 @@ final class SubscriptionManager: ObservableObject {
                 map[product.id] = product
             }
             productsByID = map
+            await refreshIntroOfferEligibility()
             await refreshPremiumAccess()
         } catch is ProductLoadError {
             if presentLoadingUI == true {
@@ -404,14 +413,44 @@ final class SubscriptionManager: ObservableObject {
         activePlan = resolvedPlan
         activeRecurringPlan = resolvedRecurringPlan
         hasResolvedPremiumStatus = true
+        UserDefaults.standard.set(hasPremium, forKey: Self.cachedPremiumAccessKey)
+    }
+
+    private func applyOptimisticPremiumAccess(for transaction: Transaction) {
+        guard SubscriptionProductID.all.contains(transaction.productID),
+              transaction.revocationDate == nil,
+              let plan = SubscriptionProductID.plan(for: transaction.productID) else { return }
+
+        hasPremiumAccess = true
+        activePlan = plan
+        if SubscriptionProductID.recurring.contains(transaction.productID) {
+            hasManageableSubscription = true
+            activeRecurringPlan = plan
+        }
+        UserDefaults.standard.set(true, forKey: Self.cachedPremiumAccessKey)
+    }
+
+    private func refreshIntroOfferEligibility() async {
+        var eligibility: [PaywallPlan: Bool] = [:]
+
+        for plan in PaywallPlan.allCases {
+            guard let product = productsByID[plan.productID],
+                  let subscription = product.subscription,
+                  let intro = subscription.introductoryOffer,
+                  intro.paymentMode == .freeTrial else { continue }
+
+            eligibility[plan] = await subscription.isEligibleForIntroOffer
+        }
+
+        introOfferEligibleByPlan = eligibility
     }
 
     private func refreshPremiumAccessWithRetry() async {
         await refreshPremiumAccess()
         guard !hasPremiumAccess else { return }
 
-        for _ in 0..<3 {
-            try? await Task.sleep(nanoseconds: 400_000_000)
+        for _ in 0..<6 {
+            try? await Task.sleep(nanoseconds: 500_000_000)
             await refreshPremiumAccess()
             if hasPremiumAccess { return }
         }
@@ -424,6 +463,7 @@ final class SubscriptionManager: ObservableObject {
         } else if let loaded = try? await Product.products(for: [productID]).first {
             product = loaded
         } else {
+            // Entitlement is already in currentEntitlements; defer to StoreKit when product metadata is unavailable.
             return true
         }
 
@@ -442,7 +482,7 @@ final class SubscriptionManager: ObservableObject {
                 }
             }
         } catch {
-            return true
+            return false
         }
     }
 

+ 5 - 1
gramora/Views/MainView.swift

@@ -33,6 +33,10 @@ struct MainView: View {
                 if viewModel.isShowingSettings {
                     SettingsView(
                         onClose: viewModel.hideSettings,
+                        onShowPaywall: {
+                            viewModel.hideSettings()
+                            viewModel.showPaywall()
+                        },
                         themeManager: themeManager
                     )
                     .environmentObject(subscriptions)
@@ -53,7 +57,7 @@ struct MainView: View {
             .environment(\.onSettingsTapped, viewModel.showSettings)
             .environment(\.requirePremiumAccess, {
                 if !subscriptions.hasResolvedPremiumStatus {
-                    return false
+                    return subscriptions.hasPremiumAccess
                 }
                 if subscriptions.hasPremiumAccess { return true }
                 viewModel.showPaywall()

+ 21 - 7
gramora/Views/SettingsView.swift

@@ -5,9 +5,11 @@ struct SettingsView: View {
     @EnvironmentObject private var subscriptions: SubscriptionManager
 
     let onClose: () -> Void
+    let onShowPaywall: () -> Void
 
-    init(onClose: @escaping () -> Void, themeManager: ThemeManager) {
+    init(onClose: @escaping () -> Void, onShowPaywall: @escaping () -> Void, themeManager: ThemeManager) {
         self.onClose = onClose
+        self.onShowPaywall = onShowPaywall
         _viewModel = StateObject(wrappedValue: SettingsViewModel(themeManager: themeManager))
     }
 
@@ -141,17 +143,29 @@ struct SettingsView: View {
                     }
                 }
 
-                if subscriptions.hasManageableSubscription {
+                if subscriptions.hasPremiumAccess {
                     SettingsRowDivider()
 
                     SettingsActionRow(
-                        iconName: "creditcard.fill",
-                        title: "Manage Subscription",
-                        subtitle: "Change plan or cancel in the App Store",
-                        actionTitle: "Manage",
+                        iconName: "crown.fill",
+                        title: subscriptions.hasManageableSubscription ? "Manage Subscription" : "View Premium",
+                        subtitle: "View plans, upgrade to lifetime, or manage billing",
+                        actionTitle: "Open",
                         isDestructive: false
                     ) {
-                        subscriptions.openSubscriptionManagement()
+                        onShowPaywall()
+                    }
+                } else {
+                    SettingsRowDivider()
+
+                    SettingsActionRow(
+                        iconName: "crown.fill",
+                        title: "Upgrade to Premium",
+                        subtitle: "Unlock all premium features",
+                        actionTitle: "View Plans",
+                        isDestructive: false
+                    ) {
+                        onShowPaywall()
                     }
                 }
             }