Skip to content

Commit

Permalink
Merge branch 'release/0.1.0'
Browse files Browse the repository at this point in the history
  • Loading branch information
mvojtkovszky committed Oct 17, 2024
2 parents efcd2eb + d4e6350 commit 00449de
Show file tree
Hide file tree
Showing 10 changed files with 799 additions and 1 deletion.
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# CHANGELOG

## 0.1.0 (2024-10-17)
* Initial Release

15 changes: 15 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// swift-tools-version: 5.8
import PackageDescription

let package = Package(
name: "StoreKitHelper",
products: [
.library(name: "StoreKitHelper", targets: ["StoreKitHelper"]),
],
dependencies: [

],
targets: [
.target(name: "StoreKitHelper", dependencies: [])
]
)
93 changes: 92 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,93 @@
# StoreKitHelper
A lightweight StoreKit wrapper designed to simplify purchases with SwiftUI.

A lightweight StoreKit2 wrapper designed to simplify purchases with SwiftUI.

## How do I get started

1. Define your in-app purchases (IAPs) or subscriptions by conforming to the `ProductRepresentable` protocol, as shown below:

```swift
enum AppProduct: String, CaseIterable, ProductRepresentable {

case iap1 = "yourApp.purchase1"
case iap2 = "yourApp.purchase2"
case subscription1 = "yourApp.subscription1"
case subscription2 = "yourApp.subscription2"

func getId() -> String {
rawValue
}
}
```

2. Initialize `PurchaseHelper`

```swift
import StoreKitHelper

struct MainView: View {
@StateObject private var purchaseHelper = PurchaseHelper(products: AppProduct.allCases)

var body: some View {
VStack {
...
}
.environmentObject(purchaseHelper)
.onAppear {
// this will handle fetching purchases and sync owned purchases
purchaseHelper.fetchAndSync()
}
}
}
```

3. Display and handle purchases anywhere in the app.

```swift
struct PaywallView: View {
@EnvironmentObject purchaseHelper: PurchaseHelper

var body: some View {
VStack {
// get purchase info with all the product data
let purchase = purchaseHelper.getProduct(AppProduct.subscription1)

// your view presenting the product data
PurchaseInfoView(purchase)
.onTapGesture {
purchaseHelper.purchase(AppProduct.subscription1)
}
}
}
.onChange(of: purchaseHelper.loadingInProgress) { newValue in
if purchaseHelper.isPurchased(AppProduct.subscription1) {
// Success!
}
}
}
}
```

Since `PurchaseHelper` is an `ObservableObject`, any change will automatically propagate to the views utilizing any of the public properties.


## Is That All?

Yes, that’s all you need to get started! There’s no unnecessary boilerplate or overcomplicated abstractions. Everything runs smoothly behind the scenes.

For more advanced configurations and details, check out the public properties and functions in the `PurchaseHelper` class.


## Installation

To integrate `StoreKitHelper` into your project, add it via Swift Package Manager or manually.

### Swift Package Manager
1. In Xcode, select `File > Add Packages`.
2. Enter the URL of the StoreKitHelper repository.
3. Choose the latest version and install.


## Contributing

Missing something or have suggestions? Feel free to open a PR, and we’ll take a look!
10 changes: 10 additions & 0 deletions Sources/StoreKitHelper/ProductRepresentable.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
//
// ProductRepresentable.swift
// StoreKitHelper
//
// Created by Marcel Vojtkovszky on 2024-10-17.
//

public protocol ProductRepresentable {
func getId() -> String
}
186 changes: 186 additions & 0 deletions Sources/StoreKitHelper/PurchaseHelper.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
//
// PurchaseHelper.swift
// StoreKitHelper
//
// Created by Marcel Vojtkovszky on 2024-10-17.
//

import Foundation
import StoreKit
import Combine

@MainActor
public class PurchaseHelper: ObservableObject {

/// Indicated if products have been fetched (if `fetchProducts` or `fetchAndSync` has been called yet)
@Published private(set) public var productsFetched: Bool = false
/// Indicated if purchases have been synced (if `syncPurchases` or `fetchAndSync` has been called yet)
@Published private(set) public var purchasesSynced: Bool = false
/// Indicates if we have all products and purchases fetched, in practice meaning we can safely use all the methods
public var purchasesReady: Bool {
return productsFetched && purchasesSynced
}
/// Is `true` whenever products are fetching, syncyng purchases is in progress, or purchase is in progress - but only one of those at once
@Published private(set) public var loadingInProgress: Bool = false

@Published private var purchasedProductIds: [String] = [] // active purchases
@Published private var products: [Product] = [] // StoreKit products

private let allProductIds: [String]
private let storeKitCommunicator = StoreKitCommunicator()


/// Initialize helper
/// - Parameters:
/// - productIds: all product ids supported by the app.
/// - listenForUpdates: if set to true, listen for transaction updates otside of the app. this can generally be omitted If you call `fetchAndSync` every time main view appears,
init(products: [ProductRepresentable]) {
self.allProductIds = products.map { $0.getId() }

Task {
await storeKitCommunicator.listenForTransactionUpdatesAsync { _ in
// result can be ignored, we just wanted to finish the transaction
}
}
}

/// Determine if a given product has been purchased.
func isPurchased(_ product: ProductRepresentable) -> Bool {
guard purchasesSynced else {
print("PurchaseHelper purchases not synced yet. Call syncPurchases() first")
return false
}
return purchasedProductIds.contains(product.getId())
}

/// Get `StoreKit`product for given app product.
/// Make sure `fetchAndSync` or `fetchProducts` is called before that
func getProduct(_ product: ProductRepresentable) -> Product? {
guard productsFetched else {
print("PurchaseHelper products not fetched yet. Call fetchProducts() first")
return nil
}
return products.first { $0.id == product.getId() }
}

/// Will initialize and handle fetching products and syncing purchases
/// Suggested to call this when view appears as it will guarantee `purchasesReady` to end up being `true`
func fetchAndSync() {
guard !loadingInProgress else {
print("PurchaseHelper purchase is in progress, fetchAndSync() ignored")
return
}

print("PurchaseHelper fetchAndSync()")
self.loadingInProgress = true
let willFetchProducts = !self.productsFetched
let productIds = self.allProductIds

Task {
if willFetchProducts {
await storeKitCommunicator.fetchProductsAsync(productIds: productIds) { products, error in
updateUI { [weak self] in
guard let self = self else { return }
if let products {
self.products = products
self.productsFetched = true
}
}
}
}
await storeKitCommunicator.syncPurchasesAsync { ids in
updateUI { [weak self] in
guard let self = self else { return }
self.purchasedProductIds = ids
self.purchasesSynced = true
}
}
updateUI { [weak self] in
guard let self = self else { return }
self.loadingInProgress = false
}
}
}

/// Fetches products from the store.
/// - While the process is in progress, `loadingInProgress` will be `true`
func fetchProducts() {
guard !loadingInProgress else {
print("PurchaseHelper purchase is in progress, fetchProducts() ignored")
return
}

print("PurchaseHelper fetchProducts()")
self.loadingInProgress = true
let productIds = self.allProductIds

Task {
await storeKitCommunicator.fetchProductsAsync(productIds: productIds) { products, error in
updateUI { [weak self] in
guard let self = self else { return }
self.products = products ?? []
self.productsFetched = true
self.loadingInProgress = false
}
}
}
}

/// Synch owned purchases (entitlements) from the store
/// - While the process is in progress, `loadingInProgress` will be `true`
func syncPurchases() {
guard !loadingInProgress else {
print("PurchaseHelper purchase is in progress, syncPurchases() ignored")
return
}

print("PurchaseHelper syncPurchases()")
self.loadingInProgress = true

Task {
await storeKitCommunicator.syncPurchasesAsync { ids in
updateUI { [weak self] in
guard let self = self else { return }
self.purchasedProductIds = ids
self.purchasesSynced = true
self.loadingInProgress = false
}
}
}
}

/// Init purchase of product
/// - Before the process starts, `purchaseFlowSuccess` will be set to `false` and set to `true` only upon successful purchase.
/// - While the process is in progress, `loadingInProgress` will be `true`
func purchase(_ product: ProductRepresentable, options: Set<Product.PurchaseOption> = []) {
guard !loadingInProgress else {
print("PurchaseHelper purchase is in progress, purchase() ignored")
return
}

print("PurchaseHelper purchase \(product.getId())")
self.loadingInProgress = true

if let storeProduct = getProduct(product) {
Task {
await storeKitCommunicator.purchaseAsync(product: storeProduct, options: options) { productId in
updateUI { [weak self] in
guard let self = self else { return }
if let productId, !self.purchasedProductIds.contains(productId) {
self.purchasedProductIds.append(productId)
}
self.loadingInProgress = false
}
}
}
} else {
print("PurchaseHelper no product found with id \(product.getId())")
}
}

private func updateUI(_ updates: @escaping () -> Void) {
DispatchQueue.main.async {
updates()
}
}
}
75 changes: 75 additions & 0 deletions Sources/StoreKitHelper/StoreKitCommunicator.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
//
// StoreKitCommunicator.swift
// StoreKitHelper
//
// Created by Marcel Vojtkovszky on 2024-10-17.
//

import Foundation
import StoreKit

internal class StoreKitCommunicator {

func fetchProductsAsync(productIds: [String], callback: ([Product]?, Error?) -> Void) async {
do {
let products = try await Product.products(for: productIds)
print("PurchaseHelper products have been fetched \(products.map({ $0.id }))")
callback(products, nil)
} catch {
print("PurchaseHelper Error fetching products: \(error)")
callback(nil, error)
}
}

func syncPurchasesAsync(callback: ([String]) -> Void) async {
var newPurchasedProductIds: [String] = []
for await result in Transaction.currentEntitlements {
if case .verified(let transaction) = result {
newPurchasedProductIds.append(transaction.productID)
await transaction.finish()
}
}
print("PurchaseHelper syncPurchases complete, purchased products: \(newPurchasedProductIds)")
callback(newPurchasedProductIds)
}

func purchaseAsync(product: Product, options: Set<Product.PurchaseOption> = [], callback: (String?) -> Void) async {
do {
let result = try await product.purchase(options: options)
switch result {
case .success(let verification):
switch verification {
case .verified(let transaction):
print("PurchaseHelper transaction verified for \(transaction.productID)")
await transaction.finish()
callback(transaction.productID)
case .unverified:
print("PurchaseHelper transaction unverified")
callback(nil)
}
case .userCancelled:
print("PurchaseHelper user canceled the purchase")
callback(nil)
case .pending:
print("PurchaseHelper purchase pending")
callback(nil)
@unknown default:
print("PurchaseHelper encountered an unknown purchase result")
callback(nil)
}
} catch {
print("PurchaseHelper failed: \(error)")
callback(nil)
}
}

func listenForTransactionUpdatesAsync(callback: (String) -> Void) async {
for await result in Transaction.updates {
if case .verified(let transaction) = result {
print("PurchaseHelper transaction updated outside the app: \(transaction.productID)")
callback(transaction.productID)
await transaction.finish()
}
}
}
}
Loading

0 comments on commit 00449de

Please sign in to comment.