Last active
November 27, 2024 20:02
-
-
Save lienmt/1f1c0d399e57a1c0eb636a60f7887e05 to your computer and use it in GitHub Desktop.
PurchaseManager with SwiftUI & StoreKit
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import Foundation | |
import StoreKit | |
enum ProductsId: String { | |
case subsWeekly24 = "WeeklyId" | |
case subsAnnual24 = "AnnualId" | |
} | |
@MainActor | |
class PurchaseManager: NSObject, ObservableObject { | |
private let productIds: [String] = [ | |
ProductsId.subsWeekly24.rawValue, | |
ProductsId.subsAnnual24.rawValue | |
] | |
@Published | |
private(set) var products: [Product] = [] | |
@Published | |
private(set) var purchasedProductIDs = Set<String>() | |
private var productsLoaded = false | |
private var updates: Task<Void, Never>? = nil | |
// MARK: Lifecycle methods | |
override init() { | |
super.init() | |
SKPaymentQueue.default().add(self) | |
updates = observeTransactionUpdates() | |
} | |
deinit { | |
updates?.cancel() | |
} | |
var isPremium: Bool { | |
!purchasedProductIDs.isEmpty | |
} | |
// MARK: Public methods | |
func loadProducts() async throws { | |
guard !self.productsLoaded else { return } | |
self.products = try await Product.products(for: productIds).sorted(by: { $0.price < $1.price }) | |
self.productsLoaded = true | |
} | |
func purchase(_ product: Product) async throws { | |
let result = try await product.purchase() | |
switch result { | |
case let .success(.verified(transaction)): | |
// Successful purhcase | |
await transaction.finish() | |
await updatePurchasedProducts() | |
case .success(.unverified(_, _)): | |
// Successful purchase but transaction/receipt can't be verified | |
// Could be a jailbroken phone | |
break | |
case .pending: | |
// Transaction waiting on SCA (Strong Customer Authentication) or | |
// approval from Ask to Buy | |
break | |
case .userCancelled: | |
// ^^^ | |
break | |
@unknown default: | |
break | |
} | |
} | |
func updatePurchasedProducts() async { | |
for await result in Transaction.currentEntitlements { | |
guard case .verified(let transaction) = result else { | |
continue | |
} | |
updatePurchasedList(transaction) | |
} | |
} | |
// MARK: Private methods | |
private func observeTransactionUpdates() -> Task<Void, Never> { | |
Task(priority: .background) { [unowned self] in | |
for await verificationResult in Transaction.updates { | |
guard case .verified(let transaction) = verificationResult else { | |
continue | |
} | |
updatePurchasedList(transaction) | |
} | |
} | |
} | |
private func updatePurchasedList(_ transaction: StoreKit.Transaction) { | |
if transaction.revocationDate == nil { | |
self.purchasedProductIDs.insert(transaction.productID) | |
} else { | |
self.purchasedProductIDs.remove(transaction.productID) | |
} | |
} | |
private func canMakePurchases() -> Bool { return SKPaymentQueue.canMakePayments() } | |
} | |
extension PurchaseManager: SKPaymentTransactionObserver { | |
func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) { | |
for transaction: AnyObject in transactions { | |
if let trans = transaction as? SKPaymentTransaction { | |
switch trans.transactionState { | |
case .purchased: | |
if let transaction = transaction as? SKPaymentTransaction { | |
purchasedProductIDs.insert(transaction.payment.productIdentifier) | |
SKPaymentQueue.default().finishTransaction(transaction) | |
} | |
case .failed: | |
SKPaymentQueue.default().finishTransaction(transaction as! SKPaymentTransaction) | |
case .restored: | |
if let transaction = transaction as? SKPaymentTransaction { | |
SKPaymentQueue.default().finishTransaction(transaction) | |
} | |
default: break | |
} | |
} | |
} | |
} | |
func paymentQueue(_ queue: SKPaymentQueue, shouldAddStorePayment payment: SKPayment, for product: SKProduct) -> Bool { | |
if canMakePurchases() { | |
let payment = SKPayment(product: product) | |
SKPaymentQueue.default().add(self) | |
SKPaymentQueue.default().add(payment) | |
return true | |
} else { | |
return false | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import SwiftUI | |
@main | |
struct YourApp: App { | |
@StateObject | |
private var purchaseManager = PurchaseManager() | |
var body: some Scene { | |
WindowGroup { | |
ContentView() | |
.environmentObject(purchaseManager) | |
.task { | |
await purchaseManager.updatePurchasedProducts() | |
} | |
} | |
} | |
} | |
struct ContentView: View { | |
@EnvironmentObject | |
private var purchaseManager: PurchaseManager | |
var body: some View { | |
VStack { | |
Text("Hello World!") | |
Text("ℹ️ is premium: \(purchaseManager.isPremium)") | |
} | |
.task { | |
do { | |
try await purchaseManager.loadProducts() | |
debugPrint("ℹ️ is premium \(purchaseManager.isPremium)") | |
} catch(let error) { | |
debugPrint("🚨 Error purchaseManager.loadProducts() \(error)") | |
} | |
} | |
} | |
} | |
#Preview { | |
ContentView() | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment