| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210 |
- import Combine
- import Foundation
- import StoreKit
- /// Product identifiers — create matching Non-Consumable + Auto-Renewable Subscription in App Store Connect.
- enum PremiumProductID: String, CaseIterable {
- case perpetual = "com.googleapps.lifetime"
- case yearly = "com.googleapps.yearly"
- }
- @MainActor
- final class PremiumStore: ObservableObject {
- static let shared = PremiumStore()
- @Published private(set) var products: [Product] = []
- @Published private(set) var isPremiumUnlocked = false
- @Published private(set) var activePremiumProductID: PremiumProductID?
- @Published private(set) var isLoadingProducts = false
- @Published private(set) var loadError: String?
- @Published private(set) var purchaseInProgress = false
- @Published private(set) var restoreInProgress = false
- @Published var purchaseError: String?
- @Published var restoreMessage: String?
- private var transactionListener: Task<Void, Never>?
- private init() {
- transactionListener = Task { await listenForTransactions() }
- Task { await refreshEntitlements() }
- }
- deinit {
- transactionListener?.cancel()
- }
- func product(for premiumID: PremiumProductID) -> Product? {
- products.first { $0.id == premiumID.rawValue }
- }
- func displayPrice(for premiumID: PremiumProductID) -> String? {
- product(for: premiumID)?.displayPrice
- }
- func loadProducts() async {
- await loadProducts(attempt: 1)
- }
- private func loadProducts(attempt: Int) async {
- isLoadingProducts = true
- loadError = nil
- defer { isLoadingProducts = false }
- do {
- let ids = PremiumProductID.allCases.map(\.rawValue)
- let loaded = try await Product.products(for: ids)
- let order = Dictionary(uniqueKeysWithValues: ids.enumerated().map { ($1, $0) })
- products = loaded.sorted { (order[$0.id] ?? Int.max) < (order[$1.id] ?? Int.max) }
- if loaded.isEmpty {
- loadError = """
- No products returned for IDs: \(ids.joined(separator: ", ")).
- If testing locally, confirm Product > Scheme > Edit Scheme > Run > Options > StoreKit Configuration = Products.storekit, then stop/run again.
- """
- } else {
- let loadedIDs = Set(loaded.map(\.id))
- let missing = ids.filter { !loadedIDs.contains($0) }
- if !missing.isEmpty {
- loadError = "Store returned partial products. Missing IDs: \(missing.joined(separator: ", "))"
- }
- }
- if products.isEmpty && attempt == 1 {
- try? await Task.sleep(nanoseconds: 800_000_000)
- await loadProducts(attempt: 2)
- }
- } catch {
- let ids = PremiumProductID.allCases.map(\.rawValue).joined(separator: ", ")
- loadError = "StoreKit error: \(error.localizedDescription). Requested IDs: \(ids)"
- if attempt == 1 {
- try? await Task.sleep(nanoseconds: 800_000_000)
- await loadProducts(attempt: 2)
- }
- }
- }
- func purchase(_ product: Product) async -> Bool {
- purchaseInProgress = true
- purchaseError = nil
- defer { purchaseInProgress = false }
- do {
- let result = try await product.purchase()
- switch result {
- case .success(let verification):
- switch verification {
- case .verified(let transaction):
- if PremiumProductID(rawValue: transaction.productID) != nil {
- isPremiumUnlocked = true
- RatingPromptManager.shared.requestRatingIfNeeded()
- }
- await transaction.finish()
- await refreshEntitlements()
- return true
- case .unverified(_, let error):
- purchaseError = error.localizedDescription
- return false
- }
- case .userCancelled:
- return false
- case .pending:
- purchaseError = "Purchase is pending (e.g. Ask to Buy)."
- return false
- @unknown default:
- return false
- }
- } catch {
- purchaseError = error.localizedDescription
- return false
- }
- }
- func restorePurchases() async {
- restoreInProgress = true
- restoreMessage = nil
- loadError = nil
- defer { restoreInProgress = false }
- do {
- try await AppStore.sync()
- await refreshEntitlements()
- if isPremiumUnlocked {
- restoreMessage = "Purchases restored successfully."
- } else {
- restoreMessage = "No active purchases were found for this Apple ID."
- }
- } catch {
- loadError = error.localizedDescription
- restoreMessage = nil
- }
- }
- func refreshEntitlements() async {
- let state = await resolvePremiumState()
- isPremiumUnlocked = state.isUnlocked
- activePremiumProductID = state.activeProductID
- }
- private func listenForTransactions() async {
- for await result in Transaction.updates {
- guard case .verified(let transaction) = result else { continue }
- await transaction.finish()
- await refreshEntitlements()
- }
- }
- private func resolvePremiumState() async -> (isUnlocked: Bool, activeProductID: PremiumProductID?) {
- let validIDs = Set(PremiumProductID.allCases.map(\.rawValue))
- var found: [PremiumProductID] = []
- for await result in Transaction.currentEntitlements {
- guard case .verified(let transaction) = result else { continue }
- guard validIDs.contains(transaction.productID) else { continue }
- if isTransactionActive(transaction) {
- if let productID = PremiumProductID(rawValue: transaction.productID) {
- found.append(productID)
- }
- }
- }
- if let preferred = preferredActiveProductID(from: found) {
- return (true, preferred)
- }
- // Fallback path for cases where `currentEntitlements` is briefly empty.
- found.removeAll()
- for id in PremiumProductID.allCases.map(\.rawValue) {
- guard let latestResult = await Transaction.latest(for: id) else { continue }
- guard case .verified(let transaction) = latestResult else { continue }
- if isTransactionActive(transaction) {
- if let productID = PremiumProductID(rawValue: transaction.productID) {
- found.append(productID)
- }
- }
- }
- if let preferred = preferredActiveProductID(from: found) {
- return (true, preferred)
- }
- return (false, nil)
- }
- private func isTransactionActive(_ transaction: Transaction) -> Bool {
- if transaction.revocationDate != nil {
- return false
- }
- if let expirationDate = transaction.expirationDate, expirationDate <= Date() {
- return false
- }
- return true
- }
- private func preferredActiveProductID(from ids: [PremiumProductID]) -> PremiumProductID? {
- if ids.contains(.perpetual) {
- return .perpetual
- }
- if ids.contains(.yearly) {
- return .yearly
- }
- return nil
- }
- }
|