Bladeren bron

Fix paywall price loading delay by caching prices and loading products in parallel.

Decouple StoreKit product fetching from entitlement refresh so prices appear instantly on repeat visits and the paywall spinner only shows when no cached prices exist.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 2 weken geleden
bovenliggende
commit
0a6613dc02

+ 64 - 24
gramora/Managers/SubscriptionManager.swift

@@ -58,6 +58,10 @@ final class SubscriptionManager: ObservableObject {
         SubscriptionProductID.all.allSatisfy { productsByID[$0] != nil }
         SubscriptionProductID.all.allSatisfy { productsByID[$0] != nil }
     }
     }
 
 
+    var hasCachedDisplayPrices: Bool {
+        SubscriptionProductID.all.allSatisfy { cachedProductSnapshots[$0] != nil }
+    }
+
     var remainingFreeAIUses: Int {
     var remainingFreeAIUses: Int {
         max(0, Self.maxFreeAIUses - freeAIUsesConsumed)
         max(0, Self.maxFreeAIUses - freeAIUsesConsumed)
     }
     }
@@ -74,10 +78,12 @@ final class SubscriptionManager: ObservableObject {
     private var inFlightProductLoad: Task<Void, Never>?
     private var inFlightProductLoad: Task<Void, Never>?
     private var launchEntitlementTask: Task<Void, Never>?
     private var launchEntitlementTask: Task<Void, Never>?
     private var entitlementRefreshTask: Task<Void, Never>?
     private var entitlementRefreshTask: Task<Void, Never>?
+    private var postProductLoadRefreshTask: Task<Void, Never>?
     private var isLaunchEntitlementRefreshInProgress = false
     private var isLaunchEntitlementRefreshInProgress = false
     private var cancellables = Set<AnyCancellable>()
     private var cancellables = Set<AnyCancellable>()
     private let defaults: UserDefaults
     private let defaults: UserDefaults
     private let entitlementCache: SubscriptionEntitlementCache
     private let entitlementCache: SubscriptionEntitlementCache
+    private var cachedProductSnapshots: [String: CachedProductSnapshot] = [:]
     /// Keeps premium UI in sync after a verified purchase until StoreKit entitlements catch up.
     /// Keeps premium UI in sync after a verified purchase until StoreKit entitlements catch up.
     private var pendingVerifiedPurchase: (plan: PaywallPlan, purchasedAt: Date)?
     private var pendingVerifiedPurchase: (plan: PaywallPlan, purchasedAt: Date)?
     private enum PremiumRefreshMode {
     private enum PremiumRefreshMode {
@@ -95,6 +101,7 @@ final class SubscriptionManager: ObservableObject {
     ) {
     ) {
         self.defaults = defaults
         self.defaults = defaults
         self.entitlementCache = entitlementCache
         self.entitlementCache = entitlementCache
+        cachedProductSnapshots = entitlementCache.loadDisplayPrices()
         freeAIUsesConsumed = min(
         freeAIUsesConsumed = min(
             defaults.integer(forKey: Self.freeAIUsesConsumedKey),
             defaults.integer(forKey: Self.freeAIUsesConsumedKey),
             Self.maxFreeAIUses
             Self.maxFreeAIUses
@@ -219,8 +226,6 @@ final class SubscriptionManager: ObservableObject {
     }
     }
 
 
     func loadProducts(presentLoadingUI: Bool? = nil) async {
     func loadProducts(presentLoadingUI: Bool? = nil) async {
-        await launchEntitlementTask?.value
-
         if let existing = inFlightProductLoad {
         if let existing = inFlightProductLoad {
             await existing.value
             await existing.value
             return
             return
@@ -240,29 +245,24 @@ final class SubscriptionManager: ObservableObject {
     }
     }
 
 
     func displayTitle(for plan: PaywallPlan) -> String {
     func displayTitle(for plan: PaywallPlan) -> String {
-        product(for: plan)?.displayName ?? plan.rawValue.capitalized
+        if let product = product(for: plan) {
+            return product.displayName
+        }
+        return cachedProductSnapshots[plan.productID]?.displayName ?? plan.rawValue.capitalized
     }
     }
 
 
     func mainPrice(for plan: PaywallPlan) -> String {
     func mainPrice(for plan: PaywallPlan) -> String {
-        product(for: plan)?.displayPrice ?? "—"
+        if let product = product(for: plan) {
+            return product.displayPrice
+        }
+        return cachedProductSnapshots[plan.productID]?.displayPrice ?? "—"
     }
     }
 
 
     func secondaryPrice(for plan: PaywallPlan) -> String {
     func secondaryPrice(for plan: PaywallPlan) -> String {
-        guard let product = product(for: plan) else { return "—" }
-
-        let formatStyle = product.priceFormatStyle
-
-        switch plan {
-        case .weekly:
-            let doubled = product.price * 2
-            return "\(doubled.formatted(formatStyle)) / week"
-        case .monthly:
-            let weekly = product.price / Decimal(4.33)
-            return "\(weekly.formatted(formatStyle)) / week"
-        case .yearly:
-            let weekly = product.price / Decimal(52)
-            return "\(weekly.formatted(formatStyle)) / week"
+        if let product = product(for: plan) {
+            return secondaryPriceText(for: plan, product: product)
         }
         }
+        return cachedProductSnapshots[plan.productID]?.secondaryPrice ?? "—"
     }
     }
 
 
     func tagText(for plan: PaywallPlan) -> String? {
     func tagText(for plan: PaywallPlan) -> String? {
@@ -444,12 +444,11 @@ final class SubscriptionManager: ObservableObject {
 
 
     private func performLoadProducts(presentLoadingUI: Bool?) async {
     private func performLoadProducts(presentLoadingUI: Bool?) async {
         let missingAny = SubscriptionProductID.all.contains { productsByID[$0] == nil }
         let missingAny = SubscriptionProductID.all.contains { productsByID[$0] == nil }
-        let shouldShowLoading = presentLoadingUI ?? missingAny
+        let shouldShowLoading = missingAny && (presentLoadingUI ?? true)
 
 
-        if !missingAny, presentLoadingUI != true {
+        if !missingAny {
             purchaseError = nil
             purchaseError = nil
-            await refreshIntroOfferEligibility()
-            await enqueuePremiumRefresh()
+            schedulePostProductLoadRefresh()
             return
             return
         }
         }
 
 
@@ -470,8 +469,8 @@ final class SubscriptionManager: ObservableObject {
                 map[product.id] = product
                 map[product.id] = product
             }
             }
             productsByID = map
             productsByID = map
-            await refreshIntroOfferEligibility()
-            await enqueuePremiumRefresh()
+            persistProductSnapshots(from: loaded)
+            schedulePostProductLoadRefresh()
         } catch is ProductLoadError {
         } catch is ProductLoadError {
             if presentLoadingUI == true {
             if presentLoadingUI == true {
                 purchaseError = .productsLoadTimeout
                 purchaseError = .productsLoadTimeout
@@ -483,6 +482,47 @@ final class SubscriptionManager: ObservableObject {
         }
         }
     }
     }
 
 
+    private func schedulePostProductLoadRefresh() {
+        postProductLoadRefreshTask?.cancel()
+        postProductLoadRefreshTask = Task { @MainActor in
+            await refreshIntroOfferEligibility()
+            guard !Task.isCancelled else { return }
+            await enqueuePremiumRefresh()
+        }
+    }
+
+    private func persistProductSnapshots(from products: [Product]) {
+        var snapshots: [String: CachedProductSnapshot] = [:]
+
+        for product in products {
+            guard let plan = SubscriptionProductID.plan(for: product.id) else { continue }
+            snapshots[product.id] = CachedProductSnapshot(
+                displayName: product.displayName,
+                displayPrice: product.displayPrice,
+                secondaryPrice: secondaryPriceText(for: plan, product: product)
+            )
+        }
+
+        cachedProductSnapshots = snapshots
+        entitlementCache.saveDisplayPrices(snapshots)
+    }
+
+    private func secondaryPriceText(for plan: PaywallPlan, product: Product) -> String {
+        let formatStyle = product.priceFormatStyle
+
+        switch plan {
+        case .weekly:
+            let doubled = product.price * 2
+            return "\(doubled.formatted(formatStyle)) / week"
+        case .monthly:
+            let weekly = product.price / Decimal(4.33)
+            return "\(weekly.formatted(formatStyle)) / week"
+        case .yearly:
+            let weekly = product.price / Decimal(52)
+            return "\(weekly.formatted(formatStyle)) / week"
+        }
+    }
+
     private func loadProductsWithTimeout(seconds: TimeInterval) async throws -> [Product] {
     private func loadProductsWithTimeout(seconds: TimeInterval) async throws -> [Product] {
         try await withThrowingTaskGroup(of: [Product].self) { group in
         try await withThrowingTaskGroup(of: [Product].self) { group in
             group.addTask {
             group.addTask {

+ 20 - 0
gramora/Services/SubscriptionEntitlementCache.swift

@@ -1,5 +1,11 @@
 import Foundation
 import Foundation
 
 
+struct CachedProductSnapshot: Codable, Equatable {
+    var displayName: String
+    var displayPrice: String
+    var secondaryPrice: String
+}
+
 struct StoredSubscriptionEntitlement: Codable, Equatable {
 struct StoredSubscriptionEntitlement: Codable, Equatable {
     var isActive: Bool
     var isActive: Bool
     var plan: String?
     var plan: String?
@@ -32,6 +38,7 @@ final class SubscriptionEntitlementCache {
     private static let legacyExpirationKey = "subscription.cachedExpiration"
     private static let legacyExpirationKey = "subscription.cachedExpiration"
     private static let pendingPurchasePlanKey = "subscription.pendingPurchasePlan"
     private static let pendingPurchasePlanKey = "subscription.pendingPurchasePlan"
     private static let pendingPurchaseDateKey = "subscription.pendingPurchaseDate"
     private static let pendingPurchaseDateKey = "subscription.pendingPurchaseDate"
+    private static let displayPricesKey = "subscription.cachedDisplayPrices"
 
 
     init(
     init(
         fileManager: FileManager = .default,
         fileManager: FileManager = .default,
@@ -97,6 +104,19 @@ final class SubscriptionEntitlementCache {
         defaults.removeObject(forKey: Self.pendingPurchaseDateKey)
         defaults.removeObject(forKey: Self.pendingPurchaseDateKey)
     }
     }
 
 
+    func loadDisplayPrices() -> [String: CachedProductSnapshot] {
+        guard let data = defaults.data(forKey: Self.displayPricesKey),
+              let decoded = try? JSONDecoder().decode([String: CachedProductSnapshot].self, from: data) else {
+            return [:]
+        }
+        return decoded
+    }
+
+    func saveDisplayPrices(_ prices: [String: CachedProductSnapshot]) {
+        guard let data = try? JSONEncoder().encode(prices) else { return }
+        defaults.set(data, forKey: Self.displayPricesKey)
+    }
+
     func clear() {
     func clear() {
         try? fileManager.removeItem(at: fileURL)
         try? fileManager.removeItem(at: fileURL)
         defaults.set(false, forKey: Self.legacyPremiumKey)
         defaults.set(false, forKey: Self.legacyPremiumKey)

+ 7 - 2
gramora/Views/PaywallView.swift

@@ -87,7 +87,9 @@ struct PaywallView: View {
                     closeButton
                     closeButton
                 }
                 }
 
 
-                if subscriptions.isLoadingProducts && !subscriptions.hasAllProductsLoaded {
+                if subscriptions.isLoadingProducts,
+                   !subscriptions.hasAllProductsLoaded,
+                   !subscriptions.hasCachedDisplayPrices {
                     Color.white.opacity(0.65)
                     Color.white.opacity(0.65)
                         .ignoresSafeArea()
                         .ignoresSafeArea()
                         .allowsHitTesting(false)
                         .allowsHitTesting(false)
@@ -98,7 +100,10 @@ struct PaywallView: View {
         }
         }
         .clearHostingBackground()
         .clearHostingBackground()
         .task {
         .task {
-            await subscriptions.loadProducts(presentLoadingUI: true)
+            guard !subscriptions.hasAllProductsLoaded else { return }
+            await subscriptions.loadProducts(
+                presentLoadingUI: !subscriptions.hasCachedDisplayPrices
+            )
         }
         }
         .alert(
         .alert(
             "Subscriptions",
             "Subscriptions",