Skip to content

Commit

Permalink
Paywalls: new .onPurchaseCancelled and `paywallViewControllerDidC…
Browse files Browse the repository at this point in the history
…ancelPurchase:`

This allows apps to be able to receive notifications when users cancel a purchase.
  • Loading branch information
NachoSoto committed Jan 16, 2024
1 parent 6f19874 commit aadbbdc
Show file tree
Hide file tree
Showing 8 changed files with 141 additions and 7 deletions.
6 changes: 5 additions & 1 deletion RevenueCatUI/Purchasing/PurchaseHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -72,19 +72,21 @@ extension PurchaseHandler {

@MainActor
func purchase(package: Package) async throws -> PurchaseResultData {
self.purchaseResult = nil

withAnimation(Constants.fastAnimation) {
self.actionInProgress = true
}
defer { self.actionInProgress = false }

let result = try await self.purchases.purchase(package: package)
self.purchaseResult = result

if result.userCancelled {
self.trackCancelledPurchase()
} else {
withAnimation(Constants.defaultAnimation) {
self.purchased = true
self.purchaseResult = result
}
}

Expand Down Expand Up @@ -206,10 +208,12 @@ struct PurchasedResultPreferenceKey: PreferenceKey {
struct PurchaseResult: Equatable {
var transaction: StoreTransaction?
var customerInfo: CustomerInfo
var userCancelled: Bool

init(data: PurchaseResultData) {
self.transaction = data.transaction
self.customerInfo = data.customerInfo
self.userCancelled = data.userCancelled
}

init?(data: PurchaseResultData?) {
Expand Down
14 changes: 11 additions & 3 deletions RevenueCatUI/UIKit/PaywallViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -82,18 +82,22 @@ public class PaywallViewController: UIViewController {
introEligibility: nil,
purchaseHandler: nil)
.onPurchaseCompleted { [weak self] transaction, customerInfo in
guard let self = self else { return }
guard let self else { return }
self.delegate?.paywallViewController?(self, didFinishPurchasingWith: customerInfo)
self.delegate?.paywallViewController?(self,
didFinishPurchasingWith: customerInfo,
transaction: transaction)
}
.onPurchaseCancelled { [weak self] in
guard let self else { return }
self.delegate?.paywallViewControllerDidCancelPurchase?(self)
}
.onRestoreCompleted { [weak self] customerInfo in
guard let self = self else { return }
guard let self else { return }
self.delegate?.paywallViewController?(self, didFinishRestoringWith: customerInfo)
}
.onSizeChange { [weak self] in
guard let self = self else { return }
guard let self else { return }
self.delegate?.paywallViewController?(self, didChangeSizeTo: $0)
}

Expand Down Expand Up @@ -144,6 +148,10 @@ public protocol PaywallViewControllerDelegate: AnyObject {
didFinishPurchasingWith customerInfo: CustomerInfo,
transaction: StoreTransaction?)

/// Notifies that a purchase has been cancelled in a ``PaywallViewController``.
@objc(paywallViewControllerDidCancelPurchase:)
optional func paywallViewControllerDidCancelPurchase(_ controller: PaywallViewController)

/// 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
12 changes: 12 additions & 0 deletions RevenueCatUI/View+PresentPaywall.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ extension View {
offering: Offering? = nil,
fonts: PaywallFontProvider = DefaultPaywallFontProvider(),
purchaseCompleted: PurchaseOrRestoreCompletedHandler? = nil,
purchaseCancelled: PurchaseCancelledHandler? = nil,
restoreCompleted: PurchaseOrRestoreCompletedHandler? = nil,
onDismiss: (() -> Void)? = nil
) -> some View {
Expand All @@ -59,6 +60,7 @@ extension View {
.contains(requiredEntitlementIdentifier)
},
purchaseCompleted: purchaseCompleted,
purchaseCancelled: purchaseCancelled,
restoreCompleted: restoreCompleted,
onDismiss: onDismiss
)
Expand All @@ -73,6 +75,8 @@ extension View {
/// !$0.entitlements.active.keys.contains("entitlement_identifier")
/// } purchaseCompleted: { customerInfo in
/// print("Customer info unlocked entitlement: \(customerInfo.entitlements)")
/// } purchaseCancelled: {
/// print("Purchase was cancelled")
/// } restoreCompleted: { customerInfo in
/// // If `entitlement_identifier` is active, paywall will dismiss automatically.
/// print("Purchases restored")
Expand All @@ -93,6 +97,7 @@ extension View {
fonts: PaywallFontProvider = DefaultPaywallFontProvider(),
shouldDisplay: @escaping @Sendable (CustomerInfo) -> Bool,
purchaseCompleted: PurchaseOrRestoreCompletedHandler? = nil,
purchaseCancelled: PurchaseCancelledHandler? = nil,
restoreCompleted: PurchaseOrRestoreCompletedHandler? = nil,
onDismiss: (() -> Void)? = nil
) -> some View {
Expand All @@ -101,6 +106,7 @@ extension View {
fonts: fonts,
shouldDisplay: shouldDisplay,
purchaseCompleted: purchaseCompleted,
purchaseCancelled: purchaseCancelled,
restoreCompleted: restoreCompleted,
onDismiss: onDismiss,
customerInfoFetcher: {
Expand All @@ -121,6 +127,7 @@ extension View {
purchaseHandler: PurchaseHandler? = nil,
shouldDisplay: @escaping @Sendable (CustomerInfo) -> Bool,
purchaseCompleted: PurchaseOrRestoreCompletedHandler? = nil,
purchaseCancelled: PurchaseCancelledHandler? = nil,
restoreCompleted: PurchaseOrRestoreCompletedHandler? = nil,
onDismiss: (() -> Void)? = nil,
customerInfoFetcher: @escaping CustomerInfoFetcher
Expand All @@ -129,6 +136,7 @@ extension View {
.modifier(PresentingPaywallModifier(
shouldDisplay: shouldDisplay,
purchaseCompleted: purchaseCompleted,
purchaseCancelled: purchaseCancelled,
restoreCompleted: restoreCompleted,
onDismiss: onDismiss,
offering: offering,
Expand All @@ -153,6 +161,7 @@ private struct PresentingPaywallModifier: ViewModifier {

var shouldDisplay: @Sendable (CustomerInfo) -> Bool
var purchaseCompleted: PurchaseOrRestoreCompletedHandler?
var purchaseCancelled: PurchaseCancelledHandler?
var restoreCompleted: PurchaseOrRestoreCompletedHandler?
var onDismiss: (() -> Void)?

Expand Down Expand Up @@ -180,6 +189,9 @@ private struct PresentingPaywallModifier: ViewModifier {
.onPurchaseCompleted {
self.purchaseCompleted?($0)
}
.onPurchaseCancelled {
self.purchaseCancelled?()
}
.onRestoreCompleted { customerInfo in
self.restoreCompleted?(customerInfo)

Expand Down
40 changes: 39 additions & 1 deletion RevenueCatUI/View+PurchaseRestoreCompleted.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ public typealias PurchaseOrRestoreCompletedHandler = @MainActor @Sendable (Custo
public typealias PurchaseCompletedHandler = @MainActor @Sendable (_ transaction: StoreTransaction?,
_ customerInfo: CustomerInfo) -> Void

/// A closure used for notifying of purchase cancellation.
public typealias PurchaseCancelledHandler = @MainActor @Sendable () -> Void

@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
@available(macOS, unavailable, message: "RevenueCatUI does not support macOS yet")
extension View {
Expand Down Expand Up @@ -79,6 +82,21 @@ extension View {
return self.modifier(OnPurchaseCompletedModifier(handler: handler))
}

/// Invokes the given closure when a purchase is cancelled.
///
/// Example:
/// ```swift
/// PaywallView()
/// .onPurchaseCancelled {
/// print("Purchase was cancelled")
/// }
/// ```
public func onPurchaseCancelled(
_ handler: @escaping PurchaseCancelledHandler
) -> some View {
return self.modifier(OnPurchaseCancelledModifier(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 @@ -129,14 +147,34 @@ private struct OnPurchaseCompletedModifier: ViewModifier {
func body(content: Content) -> some View {
content
.onPreferenceChange(PurchasedResultPreferenceKey.self) { result in
if let result {
if let result, !result.userCancelled {
self.handler(result.transaction, result.customerInfo)
}
}
}

}

@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
private struct OnPurchaseCancelledModifier: ViewModifier {

let handler: PurchaseCancelledHandler

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

func body(content: Content) -> some View {
content
.onPreferenceChange(PurchasedResultPreferenceKey.self) { result in
if let result, result.userCancelled {
self.handler()
}
}
}

}

@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
private struct OnRestoreCompletedModifier: ViewModifier {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ struct App: View {
private var fonts: PaywallFontProvider
private var purchaseOrRestoreCompleted: PurchaseOrRestoreCompletedHandler = { (_: CustomerInfo) in }
private var purchaseCompleted: PurchaseCompletedHandler = { (_: StoreTransaction?, _: CustomerInfo) in }
private var purchaseCancelled: PurchaseCancelledHandler = { () in }
private var paywallDismissed: () -> Void = {}

var body: some View {
Expand Down Expand Up @@ -64,6 +65,11 @@ struct App: View {
purchaseCompleted: self.purchaseOrRestoreCompleted,
restoreCompleted: self.purchaseOrRestoreCompleted,
onDismiss: self.paywallDismissed)
.presentPaywallIfNeeded(requiredEntitlementIdentifier: "", offering: self.offering, fonts: self.fonts,
purchaseCompleted: self.purchaseOrRestoreCompleted,
purchaseCancelled: self.purchaseCancelled,
restoreCompleted: self.purchaseOrRestoreCompleted,
onDismiss: self.paywallDismissed)
.presentPaywallIfNeeded(offering: nil) { (_: CustomerInfo) in false }
.presentPaywallIfNeeded(offering: self.offering) { (_: CustomerInfo) in false }
.presentPaywallIfNeeded(fonts: self.fonts) { (_: CustomerInfo) in false }
Expand Down Expand Up @@ -98,6 +104,17 @@ struct App: View {
} onDismiss: {
self.paywallDismissed()
}
.presentPaywallIfNeeded(offering: self.offering, fonts: self.fonts) { (_: CustomerInfo) in
false
} purchaseCompleted: {
self.purchaseOrRestoreCompleted($0)
} purchaseCancelled: {
self.purchaseCancelled()
} restoreCompleted: {
self.purchaseOrRestoreCompleted($0)
} onDismiss: {
self.paywallDismissed()
}
}

@ViewBuilder
Expand Down Expand Up @@ -140,6 +157,7 @@ struct App: View {
Text("")
.onPurchaseCompleted(self.purchaseOrRestoreCompleted)
.onPurchaseCompleted(self.purchaseCompleted)
.onPurchaseCancelled(self.purchaseCancelled)
.onRestoreCompleted(self.purchaseOrRestoreCompleted)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ final class Delegate: PaywallViewControllerDelegate {
didFinishPurchasingWith customerInfo: CustomerInfo,
transaction: StoreTransaction?) {}

func paywallViewControllerDidCancelPurchase(_ controller: PaywallViewController) {}

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

Expand Down
52 changes: 51 additions & 1 deletion Tests/RevenueCatUITests/PurchaseCompletedHandlerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ class PurchaseCompletedHandlerTests: TestCase {
.addToHierarchy()

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

Expand Down Expand Up @@ -93,6 +93,56 @@ class PurchaseCompletedHandlerTests: TestCase {
expect(result?.transaction).to(beNil())
}

func testOnPurchaseCancelled() throws {
let handler: PurchaseHandler = .cancelling()

var completed = false
var cancelled = false

try PaywallView(
offering: Self.offering.withLocalImages,
customerInfo: TestData.customerInfo,
introEligibility: .producing(eligibility: .eligible),
purchaseHandler: handler
)
.onPurchaseCancelled {
cancelled = true
}
.addToHierarchy()

Task {
_ = try await handler.purchase(package: Self.package)
completed = true
}

expect(completed).toEventually(beTrue())
expect(cancelled) == true
}

func testOnPurchaseCancelledWithCompletion() throws {
var completed = false
var cancelled = false

try PaywallView(
offering: Self.offering.withLocalImages,
customerInfo: TestData.customerInfo,
introEligibility: .producing(eligibility: .eligible),
purchaseHandler: Self.purchaseHandler
)
.onPurchaseCancelled {
cancelled = true
}
.addToHierarchy()

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

expect(completed).toEventually(beTrue())
expect(cancelled) == false
}

func testOnRestoreCompleted() throws {
var customerInfo: CustomerInfo?

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ class PurchaseHandlerTests: TestCase {
_ = try await handler.purchase(package: TestData.packageWithIntroOffer)

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

_ = try await handler.purchase(package: TestData.packageWithIntroOffer)
expect(handler.purchaseResult).to(beNil())
expect(handler.purchaseResult?.userCancelled) == true
expect(handler.purchaseResult?.customerInfo) === TestData.customerInfo
expect(handler.purchased) == false
expect(handler.actionInProgress) == false
}
Expand Down

0 comments on commit aadbbdc

Please sign in to comment.