Skip to content

Commit

Permalink
Paywalls: new .onPurchaseCompleted overload with StoreTransaction
Browse files Browse the repository at this point in the history
The current API only provided a `CustomerInfo`.
Note that this does not include `userCanceled`. We can expose separately with another `.onPurchaseCancelled` modifier in the future.
  • Loading branch information
NachoSoto committed Oct 23, 2023
1 parent 4d95c03 commit 316e2e8
Show file tree
Hide file tree
Showing 9 changed files with 142 additions and 41 deletions.
4 changes: 2 additions & 2 deletions RevenueCatUI/PaywallView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -274,8 +274,8 @@ struct LoadedOfferingPaywallView: View {
locale: self.locale)
.environmentObject(self.introEligibility)
.environmentObject(self.purchaseHandler)
.preference(key: PurchasedCustomerInfoPreferenceKey.self,
value: self.purchaseHandler.purchasedCustomerInfo)
.preference(key: PurchasedResultPreferenceKey.self,
value: .init(data: self.purchaseHandler.purchaseResult))
.preference(key: RestoredCustomerInfoPreferenceKey.self,
value: self.purchaseHandler.restoredCustomerInfo)
.disabled(self.purchaseHandler.actionInProgress)
Expand Down
1 change: 1 addition & 0 deletions RevenueCatUI/Purchasing/PurchaseHandler+TestData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ extension PurchaseHandler {
return self.init(
purchases: MockPurchases { _ in
return (
// No current way to create a mock transaction with RevenueCat's public methods.
transaction: nil,
customerInfo: customerInfo,
userCancelled: false
Expand Down
25 changes: 20 additions & 5 deletions RevenueCatUI/Purchasing/PurchaseHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ final class PurchaseHandler: ObservableObject {

/// When `purchased` becomes `true`, this will include the `CustomerInfo` associated to it.
@Published
fileprivate(set) var purchasedCustomerInfo: CustomerInfo?
fileprivate(set) var purchaseResult: PurchaseResultData?

/// Whether a restore was successfully completed.
@Published
Expand Down Expand Up @@ -84,7 +84,7 @@ extension PurchaseHandler {
} else {
withAnimation(Constants.defaultAnimation) {
self.purchased = true
self.purchasedCustomerInfo = result.customerInfo
self.purchaseResult = result
}
}

Expand Down Expand Up @@ -189,11 +189,26 @@ private final class NotConfiguredPurchases: PaywallPurchasesType {
// MARK: - Preference Keys

@available(iOS 15.0, macOS 12.0, tvOS 15.0, *)
struct PurchasedCustomerInfoPreferenceKey: PreferenceKey {
struct PurchasedResultPreferenceKey: PreferenceKey {

static var defaultValue: CustomerInfo?
struct PurchaseResult: Equatable {
var transaction: StoreTransaction?
var customerInfo: CustomerInfo

static func reduce(value: inout CustomerInfo?, nextValue: () -> CustomerInfo?) {
init(data: PurchaseResultData) {
self.transaction = data.transaction
self.customerInfo = data.customerInfo
}

init?(data: PurchaseResultData?) {
guard let data else { return nil }
self.init(data: data)
}
}

static var defaultValue: PurchaseResult?

static func reduce(value: inout PurchaseResult?, nextValue: () -> PurchaseResult?) {
value = nextValue()
}

Expand Down
11 changes: 10 additions & 1 deletion RevenueCatUI/UIKit/PaywallViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,12 @@ public final class PaywallViewController: UIViewController {
let paywallView = self.offering.map { PaywallView(offering: $0) } ?? PaywallView()

let view = paywallView
.onPurchaseCompleted { [weak self] customerInfo in
.onPurchaseCompleted { [weak self] transaction, customerInfo in
guard let self = self else { return }
self.delegate?.paywallViewController?(self, didFinishPurchasingWith: customerInfo)
self.delegate?.paywallViewController?(self,
didFinishPurchasingWith: customerInfo,
transaction: transaction)
}
.onRestoreCompleted { [weak self] customerInfo in
guard let self = self else { return }
Expand Down Expand Up @@ -83,6 +86,12 @@ public protocol PaywallViewControllerDelegate: AnyObject {
optional func paywallViewController(_ controller: PaywallViewController,
didFinishPurchasingWith customerInfo: CustomerInfo)

/// Notifies that a purchase has completed in a ``PaywallViewController``.
@objc(paywallViewController:didFinishPurchasingWithCustomerInfo:transaction:)
optional func paywallViewController(_ controller: PaywallViewController,
didFinishPurchasingWith customerInfo: CustomerInfo,
transaction: StoreTransaction?)

/// Notifies that the restore operation has completed in a ``PaywallViewController``.
///
/// - Warning: Receiving a ``CustomerInfo``does not imply that the user has any entitlements,
Expand Down
49 changes: 44 additions & 5 deletions RevenueCatUI/View+PurchaseRestoreCompleted.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ import SwiftUI
/// A closure used for notifying of purchase or restore completion.
public typealias PurchaseOrRestoreCompletedHandler = @MainActor @Sendable (CustomerInfo) -> Void

/// A closure used for notifying of purchase completion.
public typealias PurchaseCompletedHandler = @MainActor @Sendable (_ transaction: StoreTransaction?,
_ customerInfo: CustomerInfo) -> Void

@available(iOS 15.0, macOS 12.0, tvOS 15.0, *)
@available(macOS, unavailable, message: "RevenueCatUI does not support macOS yet")
extension View {
Expand All @@ -34,7 +38,7 @@ extension View {
/// PaywallView()
/// .onPurchaseCompleted { customerInfo in
/// print("Purchase completed: \(customerInfo.entitlements)")
/// self.didPurchase = false
/// self.displayPaywall = false
/// }
/// }
/// }
Expand All @@ -48,6 +52,33 @@ extension View {
return self.modifier(OnPurchaseCompletedModifier(handler: handler))
}

/// Invokes the given closure when a purchase is completed.
/// The closure includes the `CustomerInfo` with unlocked entitlements.
/// Example:
/// ```swift
/// @State
/// private var displayPaywall: Bool = true
///
/// var body: some View {
/// ContentView()
/// .sheet(isPresented: self.$displayPaywall) {
/// PaywallView()
/// .onPurchaseCompleted { transaction, customerInfo in
/// print("Purchase completed: \(customerInfo.entitlements)")
/// self.displayPaywall = false
/// }
/// }
/// }
/// ```
///
/// ### Related Articles
/// [Documentation](https://rev.cat/paywalls)
public func onPurchaseCompleted(
_ handler: @escaping PurchaseCompletedHandler
) -> some View {
return self.modifier(OnPurchaseCompletedModifier(handler: handler))
}

/// Invokes the given closure when restore purchases is completed.
/// The closure includes the `CustomerInfo` after the process is completed.
/// Example:
Expand Down Expand Up @@ -85,13 +116,21 @@ extension View {
@available(iOS 15.0, macOS 12.0, tvOS 15.0, *)
private struct OnPurchaseCompletedModifier: ViewModifier {

let handler: PurchaseOrRestoreCompletedHandler
let handler: PurchaseCompletedHandler

init(handler: @escaping PurchaseOrRestoreCompletedHandler) {
self.handler = { _, customerInfo in handler(customerInfo) }
}

init(handler: @escaping PurchaseCompletedHandler) {
self.handler = handler
}

func body(content: Content) -> some View {
content
.onPreferenceChange(PurchasedCustomerInfoPreferenceKey.self) { customerInfo in
if let customerInfo {
self.handler(customerInfo)
.onPreferenceChange(PurchasedResultPreferenceKey.self) { result in
if let result {
self.handler(result.transaction, result.customerInfo)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ struct App: View {
private var offering: Offering
private var fonts: PaywallFontProvider
private var completed: PurchaseOrRestoreCompletedHandler = { (_: CustomerInfo) in }
private var purchaseCompleted: PurchaseCompletedHandler = { (_: CustomerInfo, _: StoreTransaction?) in }

var body: some View {
self.content
Expand All @@ -35,24 +36,29 @@ struct App: View {
Text("")
.presentPaywallIfNeeded(requiredEntitlementIdentifier: "")
.presentPaywallIfNeeded(requiredEntitlementIdentifier: "", fonts: self.fonts)
.presentPaywallIfNeeded(requiredEntitlementIdentifier: "", purchaseCompleted: completed)
.presentPaywallIfNeeded(requiredEntitlementIdentifier: "", restoreCompleted: completed)
.presentPaywallIfNeeded(requiredEntitlementIdentifier: "", fonts: self.fonts, purchaseCompleted: completed)
.presentPaywallIfNeeded(requiredEntitlementIdentifier: "", fonts: self.fonts, restoreCompleted: completed)
.presentPaywallIfNeeded(requiredEntitlementIdentifier: "", fonts: self.fonts, purchaseCompleted: completed,
restoreCompleted: completed)
.presentPaywallIfNeeded(requiredEntitlementIdentifier: "",
purchaseCompleted: self.purchaseOrRestoreCompleted)
.presentPaywallIfNeeded(requiredEntitlementIdentifier: "",
restoreCompleted: self.purchaseOrRestoreCompleted)
.presentPaywallIfNeeded(requiredEntitlementIdentifier: "", fonts: self.fonts,
purchaseCompleted: self.purchaseOrRestoreCompleted)
.presentPaywallIfNeeded(requiredEntitlementIdentifier: "", fonts: self.fonts,
restoreCompleted: self.purchaseOrRestoreCompleted)
.presentPaywallIfNeeded(requiredEntitlementIdentifier: "", fonts: self.fonts,
purchaseCompleted: self.purchaseOrRestoreCompleted,
restoreCompleted: self.purchaseOrRestoreCompleted)
.presentPaywallIfNeeded(fonts: self.fonts) { (_: CustomerInfo) in false }
.presentPaywallIfNeeded(fonts: self.fonts) { (_: CustomerInfo) in
false
} purchaseCompleted: {
completed($0)
self.purchaseOrRestoreCompleted($0)
}
.presentPaywallIfNeeded(fonts: self.fonts) { (_: CustomerInfo) in
false
} purchaseCompleted: {
completed($0)
self.purchaseOrRestoreCompleted($0)
} restoreCompleted: {
completed($0)
self.purchaseOrRestoreCompleted($0)
}
}

Expand All @@ -61,37 +67,41 @@ struct App: View {
Text("")
.paywallFooter()
.paywallFooter(fonts: self.fonts)
.paywallFooter(purchaseCompleted: completed)
.paywallFooter(fonts: self.fonts, purchaseCompleted: completed)
.paywallFooter(purchaseCompleted: self.purchaseOrRestoreCompleted)
.paywallFooter(fonts: self.fonts, purchaseCompleted: self.purchaseOrRestoreCompleted)
.paywallFooter(condensed: true)
.paywallFooter(condensed: true, fonts: self.fonts)
.paywallFooter(condensed: true, purchaseCompleted: completed)
.paywallFooter(condensed: true, fonts: self.fonts, purchaseCompleted: completed)
.paywallFooter(condensed: true, purchaseCompleted: self.purchaseOrRestoreCompleted)
.paywallFooter(condensed: true, fonts: self.fonts, purchaseCompleted: self.purchaseOrRestoreCompleted)
.paywallFooter(condensed: true, fonts: self.fonts,
purchaseCompleted: completed, restoreCompleted: completed)
purchaseCompleted: self.purchaseOrRestoreCompleted,
restoreCompleted: self.purchaseOrRestoreCompleted)

.paywallFooter(offering: offering)
.paywallFooter(offering: offering, condensed: true)
.paywallFooter(offering: offering, condensed: true, fonts: self.fonts)
.paywallFooter(offering: offering, condensed: true, purchaseCompleted: completed)
.paywallFooter(offering: offering, condensed: true, restoreCompleted: completed)
.paywallFooter(offering: offering, condensed: true, fonts: self.fonts, purchaseCompleted: completed)
.paywallFooter(offering: offering, condensed: true, purchaseCompleted: self.purchaseOrRestoreCompleted)
.paywallFooter(offering: offering, condensed: true, restoreCompleted: self.purchaseOrRestoreCompleted)
.paywallFooter(offering: offering, condensed: true, fonts: self.fonts,
purchaseCompleted: completed, restoreCompleted: completed)
purchaseCompleted: self.purchaseOrRestoreCompleted)
.paywallFooter(offering: offering, condensed: true, fonts: self.fonts,
purchaseCompleted: self.purchaseOrRestoreCompleted,
restoreCompleted: self.purchaseOrRestoreCompleted)
.paywallFooter(offering: offering)
.paywallFooter(offering: offering, fonts: self.fonts)
.paywallFooter(offering: offering, purchaseCompleted: completed)
.paywallFooter(offering: offering, restoreCompleted: completed)
.paywallFooter(offering: offering, fonts: self.fonts, purchaseCompleted: completed)
.paywallFooter(offering: offering, purchaseCompleted: self.purchaseOrRestoreCompleted)
.paywallFooter(offering: offering, restoreCompleted: self.purchaseOrRestoreCompleted)
.paywallFooter(offering: offering, fonts: self.fonts, purchaseCompleted: self.purchaseOrRestoreCompleted)
.paywallFooter(offering: offering, fonts: self.fonts,
purchaseCompleted: completed, restoreCompleted: completed)
purchaseCompleted: completed, restoreCompleted: self.purchaseOrRestoreCompleted)
}

@ViewBuilder
var checkOnPurchaseAndRestoreCompleted: some View {
Text("")
.onPurchaseCompleted(self.completed)
.onRestoreCompleted(self.completed)
.onPurchaseCompleted(self.purchaseOrRestoreCompleted)
.onPurchaseCompleted(self.purchaseCompleted)
.onRestoreCompleted(self.purchaseOrRestoreCompleted)
}

private func fontProviders() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ final class Delegate: PaywallViewControllerDelegate {
func paywallViewController(_ controller: PaywallViewController,
didFinishPurchasingWith customerInfo: CustomerInfo) {}

func paywallViewController(_ controller: PaywallViewController,
didFinishPurchasingWith customerInfo: CustomerInfo,
transaction: StoreTransaction?) {}

func paywallViewController(_ controller: PaywallViewController,
didFinishRestoringWith customerInfo: CustomerInfo) {}

Expand Down
23 changes: 23 additions & 0 deletions Tests/RevenueCatUITests/PurchaseCompletedHandlerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,29 @@ class PurchaseCompletedHandlerTests: TestCase {
expect(customerInfo).toEventually(be(TestData.customerInfo))
}

func testOnPurchaseCompletedWithTransaction() throws {
var result: (transaction: StoreTransaction?, customerInfo: CustomerInfo)?

try PaywallView(
offering: Self.offering.withLocalImages,
customerInfo: TestData.customerInfo,
introEligibility: .producing(eligibility: .eligible),
purchaseHandler: Self.purchaseHandler
)
.onPurchaseCompleted { transaction, customerInfo in
result = (transaction, customerInfo)
}
.addToHierarchy()

Task {
_ = try await Self.purchaseHandler.purchase(package: Self.package)
}

expect(result).toEventuallyNot(beNil())
expect(result?.customerInfo) === TestData.customerInfo
expect(result?.transaction).to(beNil())
}

func testOnRestoreCompleted() throws {
var customerInfo: CustomerInfo?

Expand Down
8 changes: 4 additions & 4 deletions Tests/RevenueCatUITests/Purchasing/PurchaseHandlerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ class PurchaseHandlerTests: TestCase {
func testInitialState() async throws {
let handler: PurchaseHandler = .mock()

expect(handler.purchasedCustomerInfo).to(beNil())
expect(handler.purchaseResult).to(beNil())
expect(handler.restoredCustomerInfo).to(beNil())
expect(handler.purchased) == false
expect(handler.restored) == false
Expand All @@ -37,7 +37,7 @@ class PurchaseHandlerTests: TestCase {

_ = try await handler.purchase(package: TestData.packageWithIntroOffer)

expect(handler.purchasedCustomerInfo) === TestData.customerInfo
expect(handler.purchaseResult?.customerInfo) === TestData.customerInfo
expect(handler.restoredCustomerInfo).to(beNil())
expect(handler.purchased) == true
expect(handler.actionInProgress) == false
Expand All @@ -47,7 +47,7 @@ class PurchaseHandlerTests: TestCase {
let handler: PurchaseHandler = .cancelling()

_ = try await handler.purchase(package: TestData.packageWithIntroOffer)
expect(handler.purchasedCustomerInfo).to(beNil())
expect(handler.purchaseResult).to(beNil())
expect(handler.purchased) == false
expect(handler.actionInProgress) == false
}
Expand All @@ -61,7 +61,7 @@ class PurchaseHandlerTests: TestCase {
expect(result.success) == false
expect(handler.restored) == true
expect(handler.restoredCustomerInfo) === TestData.customerInfo
expect(handler.purchasedCustomerInfo).to(beNil())
expect(handler.purchaseResult).to(beNil())
expect(handler.actionInProgress) == false
}

Expand Down

0 comments on commit 316e2e8

Please sign in to comment.