Selaa lähdekoodia

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 viikkoa sitten
vanhempi
sitoutus
0a6613dc02

+ 64 - 24
gramora/Managers/SubscriptionManager.swift

@@ -58,6 +58,10 @@ final class SubscriptionManager: ObservableObject {
         SubscriptionProductID.all.allSatisfy { productsByID[$0] != nil }
     }
 
+    var hasCachedDisplayPrices: Bool {
+        SubscriptionProductID.all.allSatisfy { cachedProductSnapshots[$0] != nil }
+    }
+
     var remainingFreeAIUses: Int {
         max(0, Self.maxFreeAIUses - freeAIUsesConsumed)
     }
@@ -74,10 +78,12 @@ final class SubscriptionManager: ObservableObject {
     private var inFlightProductLoad: Task<Void, Never>?
     private var launchEntitlementTask: Task<Void, Never>?
     private var entitlementRefreshTask: Task<Void, Never>?
+    private var postProductLoadRefreshTask: Task<Void, Never>?
     private var isLaunchEntitlementRefreshInProgress = false
     private var cancellables = Set<AnyCancellable>()
     private let defaults: UserDefaults
     private let entitlementCache: SubscriptionEntitlementCache
+    private var cachedProductSnapshots: [String: CachedProductSnapshot] = [:]
     /// Keeps premium UI in sync after a verified purchase until StoreKit entitlements catch up.
     private var pendingVerifiedPurchase: (plan: PaywallPlan, purchasedAt: Date)?
     private enum PremiumRefreshMode {
@@ -95,6 +101,7 @@ final class SubscriptionManager: ObservableObject {
     ) {
         self.defaults = defaults
         self.entitlementCache = entitlementCache
+        cachedProductSnapshots = entitlementCache.loadDisplayPrices()
         freeAIUsesConsumed = min(
             defaults.integer(forKey: Self.freeAIUsesConsumedKey),
             Self.maxFreeAIUses
@@ -219,8 +226,6 @@ final class SubscriptionManager: ObservableObject {
     }
 
     func loadProducts(presentLoadingUI: Bool? = nil) async {
-        await launchEntitlementTask?.value
-
         if let existing = inFlightProductLoad {
             await existing.value
             return
@@ -240,29 +245,24 @@ final class SubscriptionManager: ObservableObject {
     }
 
     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 {
-        product(for: plan)?.displayPrice ?? "—"
+        if let product = product(for: plan) {
+            return product.displayPrice
+        }
+        return cachedProductSnapshots[plan.productID]?.displayPrice ?? "—"
     }
 
     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? {
@@ -444,12 +444,11 @@ final class SubscriptionManager: ObservableObject {
 
     private func performLoadProducts(presentLoadingUI: Bool?) async {
         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
-            await refreshIntroOfferEligibility()
-            await enqueuePremiumRefresh()
+            schedulePostProductLoadRefresh()
             return
         }
 
@@ -470,8 +469,8 @@ final class SubscriptionManager: ObservableObject {
                 map[product.id] = product
             }
             productsByID = map
-            await refreshIntroOfferEligibility()
-            await enqueuePremiumRefresh()
+            persistProductSnapshots(from: loaded)
+            schedulePostProductLoadRefresh()
         } catch is ProductLoadError {
             if presentLoadingUI == true {
                 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] {
         try await withThrowingTaskGroup(of: [Product].self) { group in
             group.addTask {

+ 20 - 0
gramora/Services/SubscriptionEntitlementCache.swift

@@ -1,5 +1,11 @@
 import Foundation
 
+struct CachedProductSnapshot: Codable, Equatable {
+    var displayName: String
+    var displayPrice: String
+    var secondaryPrice: String
+}
+
 struct StoredSubscriptionEntitlement: Codable, Equatable {
     var isActive: Bool
     var plan: String?
@@ -32,6 +38,7 @@ final class SubscriptionEntitlementCache {
     private static let legacyExpirationKey = "subscription.cachedExpiration"
     private static let pendingPurchasePlanKey = "subscription.pendingPurchasePlan"
     private static let pendingPurchaseDateKey = "subscription.pendingPurchaseDate"
+    private static let displayPricesKey = "subscription.cachedDisplayPrices"
 
     init(
         fileManager: FileManager = .default,
@@ -97,6 +104,19 @@ final class SubscriptionEntitlementCache {
         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() {
         try? fileManager.removeItem(at: fileURL)
         defaults.set(false, forKey: Self.legacyPremiumKey)

+ 7 - 2
gramora/Views/PaywallView.swift

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