diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6a11522 --- /dev/null +++ b/.gitignore @@ -0,0 +1,96 @@ +# Xcode +# +DerivedData/ +build/ +*.xcworkspace +!default.xcworkspace +xcuserdata/ +*.xcodeproj/* +!*.xcodeproj/project.pbxproj +!*.xcodeproj/xcshareddata/ +!*.xcworkspace/contents.xcworkspacedata +*.xcuserstate +*.xcscmblueprint +*.swifttemplate + +# Swift Package Manager +# +.build/ +Packages/ +Package.pins +.swiftpm/xcode/package.xcworkspace/ +.swiftpm/xcode/xcshareddata/ + +# CocoaPods +# +Pods/ +Podfile.lock +Podfile/.swift-version + +# Carthage +# +Carthage/Build/ + +# Fastlane +# +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots +fastlane/test_output +fastlane/tests_output + +# Xcode Specific +# +*.moved-aside +*.hmap +*.ipa +*.dSYM.zip +*.dSYM +*.xcarchive +.xcresult/ + +# Playgrounds +# +timeline.xctimeline +playground.xcworkspace + +# Shared frameworks +# +Carthage/Build +Carthage/Checkouts + +# Generated Files +# +*.rpt +*.sav + +# App packaging +# +*.pmdoc + +# SwiftPM Binary Artifacts +.xcframeworks/ + +# Temporary files +# +*.tmp +*.temp +*.swp +*.swo +*.DS_Store +*.class +*.lock + +# Testing +# +test_output/ + +# Environment specific files +# +.vscode/ +.env + +# Editor specific +# +.idea/ +*.iml \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index e6b74ff..c1931ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # CHANGELOG +## 0.1.1 (2024-10-17) +* Ensure functions intended to be public are indeed public :) +* Add `autoFinishTransactions` option to init. +* Improve documentation and examples in README.md + ## 0.1.0 (2024-10-17) * Initial Release diff --git a/Package.swift b/Package.swift index 6ee8642..0f7e1b3 100644 --- a/Package.swift +++ b/Package.swift @@ -3,6 +3,9 @@ import PackageDescription let package = Package( name: "StoreKitHelper", + platforms: [ + .iOS(.v16) + ], products: [ .library(name: "StoreKitHelper", targets: ["StoreKitHelper"]), ], diff --git a/README.md b/README.md index 41d0714..7462b93 100644 --- a/README.md +++ b/README.md @@ -4,16 +4,16 @@ 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: +1. Define your in-app purchases (IAPs) or subscriptions by conforming to the `ProductRepresentable` protocol, for example: ```swift enum AppProduct: String, CaseIterable, ProductRepresentable { - case iap1 = "yourApp.purchase1" case iap2 = "yourApp.purchase2" case subscription1 = "yourApp.subscription1" case subscription2 = "yourApp.subscription2" + // ProductRepresentable requires this func getId() -> String { rawValue } @@ -23,8 +23,6 @@ enum AppProduct: String, CaseIterable, ProductRepresentable { 2. Initialize `PurchaseHelper` ```swift -import StoreKitHelper - struct MainView: View { @StateObject private var purchaseHelper = PurchaseHelper(products: AppProduct.allCases) @@ -34,14 +32,14 @@ struct MainView: View { } .environmentObject(purchaseHelper) .onAppear { - // this will handle fetching purchases and sync owned purchases + // this will handle fetching purchases and syncing owned purchases purchaseHelper.fetchAndSync() } } } ``` -3. Display and handle purchases anywhere in the app. +3. Display and handle product purchases anywhere in the app. ```swift struct PaywallView: View { @@ -49,18 +47,22 @@ struct PaywallView: View { 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) + if let product = purchaseHelper.getProduct(AppProduct.subscription1) { + VStack { + // title and price + Text(product.displayName) + Text(product.displayPrice) + // subscribe button + Button("Subscribe") { + // init purchase flow + purchaseHelper.purchase(AppProduct.subscription1) + } + .disabled(purchaseHelper.loadingInProgress) } } } - .onChange(of: purchaseHelper.loadingInProgress) { newValue in - if purchaseHelper.isPurchased(AppProduct.subscription1) { + .onChange(of: purchaseHelper.isPurchased(AppProduct.subscription1)) { isPurchased in + if isPurchased { // Success! } } @@ -68,7 +70,7 @@ struct PaywallView: View { } ``` -Since `PurchaseHelper` is an `ObservableObject`, any change will automatically propagate to the views utilizing any of the public properties. +Since `PurchaseHelper` is an `ObservableObject`, any change will automatically propagate to the views utilizing any of the public properties or getters using those properties, as demonstrated in the example above. ## Is That All? @@ -80,7 +82,7 @@ For more advanced configurations and details, check out the public properties an ## Installation -To integrate `StoreKitHelper` into your project, add it via Swift Package Manager or manually. +To integrate it into your project, add it via Swift Package Manager or manually. ### Swift Package Manager 1. In Xcode, select `File > Add Packages`. diff --git a/Sources/StoreKitHelper/ProductRepresentable.swift b/Sources/StoreKitHelper/ProductRepresentable.swift index 6a1ee5e..3685399 100644 --- a/Sources/StoreKitHelper/ProductRepresentable.swift +++ b/Sources/StoreKitHelper/ProductRepresentable.swift @@ -6,5 +6,6 @@ // public protocol ProductRepresentable { + /// Identifier for a product func getId() -> String } diff --git a/Sources/StoreKitHelper/PurchaseHelper.swift b/Sources/StoreKitHelper/PurchaseHelper.swift index 1c98630..0a0caff 100644 --- a/Sources/StoreKitHelper/PurchaseHelper.swift +++ b/Sources/StoreKitHelper/PurchaseHelper.swift @@ -27,15 +27,19 @@ public class PurchaseHelper: ObservableObject { @Published private var products: [Product] = [] // StoreKit products private let allProductIds: [String] - private let storeKitCommunicator = StoreKitCommunicator() + 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]) { + /// - products: all product ids supported by the app. + /// - autoFinishTransactions: call `Transaction.finish()` on verified transactions. `true` by default. Let it be unless you verify your transaction on own backend. + public init( + products: [ProductRepresentable], + autoFinishTransactions: Bool = true + ) { self.allProductIds = products.map { $0.getId() } + self.storeKitCommunicator = StoreKitCommunicator(autoFinishTransactions: autoFinishTransactions) Task { await storeKitCommunicator.listenForTransactionUpdatesAsync { _ in @@ -45,7 +49,7 @@ public class PurchaseHelper: ObservableObject { } /// Determine if a given product has been purchased. - func isPurchased(_ product: ProductRepresentable) -> Bool { + public func isPurchased(_ product: ProductRepresentable) -> Bool { guard purchasesSynced else { print("PurchaseHelper purchases not synced yet. Call syncPurchases() first") return false @@ -55,7 +59,7 @@ public class PurchaseHelper: ObservableObject { /// Get `StoreKit`product for given app product. /// Make sure `fetchAndSync` or `fetchProducts` is called before that - func getProduct(_ product: ProductRepresentable) -> Product? { + public func getProduct(_ product: ProductRepresentable) -> Product? { guard productsFetched else { print("PurchaseHelper products not fetched yet. Call fetchProducts() first") return nil @@ -63,9 +67,9 @@ public class PurchaseHelper: ObservableObject { return products.first { $0.id == product.getId() } } - /// Will initialize and handle fetching products and syncing purchases + /// Will initialize and handle fetching products and syncing purchases sequentially. /// Suggested to call this when view appears as it will guarantee `purchasesReady` to end up being `true` - func fetchAndSync() { + public func fetchAndSync() { guard !loadingInProgress else { print("PurchaseHelper purchase is in progress, fetchAndSync() ignored") return @@ -102,9 +106,9 @@ public class PurchaseHelper: ObservableObject { } } - /// Fetches products from the store. + /// Fetches products from the store. You can then retrieve a product by calling `getProduct()` /// - While the process is in progress, `loadingInProgress` will be `true` - func fetchProducts() { + public func fetchProducts() { guard !loadingInProgress else { print("PurchaseHelper purchase is in progress, fetchProducts() ignored") return @@ -128,7 +132,7 @@ public class PurchaseHelper: ObservableObject { /// Synch owned purchases (entitlements) from the store /// - While the process is in progress, `loadingInProgress` will be `true` - func syncPurchases() { + public func syncPurchases() { guard !loadingInProgress else { print("PurchaseHelper purchase is in progress, syncPurchases() ignored") return @@ -149,10 +153,9 @@ public class PurchaseHelper: ObservableObject { } } - /// Init purchase of product - /// - Before the process starts, `purchaseFlowSuccess` will be set to `false` and set to `true` only upon successful purchase. + /// Init purchase of a given product, with optionally provided `options` /// - While the process is in progress, `loadingInProgress` will be `true` - func purchase(_ product: ProductRepresentable, options: Set = []) { + public func purchase(_ product: ProductRepresentable, options: Set = []) { guard !loadingInProgress else { print("PurchaseHelper purchase is in progress, purchase() ignored") return diff --git a/Sources/StoreKitHelper/StoreKitCommunicator.swift b/Sources/StoreKitHelper/StoreKitCommunicator.swift index dead27e..3fea673 100644 --- a/Sources/StoreKitHelper/StoreKitCommunicator.swift +++ b/Sources/StoreKitHelper/StoreKitCommunicator.swift @@ -9,6 +9,11 @@ import Foundation import StoreKit internal class StoreKitCommunicator { + private let autoFinishTransactions: Bool + + init(autoFinishTransactions: Bool) { + self.autoFinishTransactions = autoFinishTransactions + } func fetchProductsAsync(productIds: [String], callback: ([Product]?, Error?) -> Void) async { do { @@ -25,8 +30,10 @@ internal class StoreKitCommunicator { var newPurchasedProductIds: [String] = [] for await result in Transaction.currentEntitlements { if case .verified(let transaction) = result { + if autoFinishTransactions { + await transaction.finish() + } newPurchasedProductIds.append(transaction.productID) - await transaction.finish() } } print("PurchaseHelper syncPurchases complete, purchased products: \(newPurchasedProductIds)") @@ -41,7 +48,9 @@ internal class StoreKitCommunicator { switch verification { case .verified(let transaction): print("PurchaseHelper transaction verified for \(transaction.productID)") - await transaction.finish() + if autoFinishTransactions { + await transaction.finish() + } callback(transaction.productID) case .unverified: print("PurchaseHelper transaction unverified") @@ -67,8 +76,10 @@ internal class StoreKitCommunicator { for await result in Transaction.updates { if case .verified(let transaction) = result { print("PurchaseHelper transaction updated outside the app: \(transaction.productID)") + if autoFinishTransactions { + await transaction.finish() + } callback(transaction.productID) - await transaction.finish() } } } diff --git a/StoreKitHelper.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/StoreKitHelper.xcodeproj/project.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index 919434a..0000000 --- a/StoreKitHelper.xcodeproj/project.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/StoreKitHelper.xcodeproj/xcuserdata/marcel.xcuserdatad/xcschemes/xcschememanagement.plist b/StoreKitHelper.xcodeproj/xcuserdata/marcel.xcuserdatad/xcschemes/xcschememanagement.plist deleted file mode 100644 index 052a8e3..0000000 --- a/StoreKitHelper.xcodeproj/xcuserdata/marcel.xcuserdatad/xcschemes/xcschememanagement.plist +++ /dev/null @@ -1,14 +0,0 @@ - - - - - SchemeUserState - - StoreKitHelper.xcscheme_^#shared#^_ - - orderHint - 0 - - - -