|
@@ -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 {
|