diff --git a/RevenueCatUI/Purchasing/PurchaseHandler.swift b/RevenueCatUI/Purchasing/PurchaseHandler.swift index b0d3f9c1b4..a5f72949a8 100644 --- a/RevenueCatUI/Purchasing/PurchaseHandler.swift +++ b/RevenueCatUI/Purchasing/PurchaseHandler.swift @@ -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 } } @@ -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?) { diff --git a/RevenueCatUI/UIKit/PaywallViewController.swift b/RevenueCatUI/UIKit/PaywallViewController.swift index 136457a015..ce8ce609b2 100644 --- a/RevenueCatUI/UIKit/PaywallViewController.swift +++ b/RevenueCatUI/UIKit/PaywallViewController.swift @@ -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) } @@ -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, diff --git a/RevenueCatUI/View+PresentPaywall.swift b/RevenueCatUI/View+PresentPaywall.swift index bd85e78c13..be3858797a 100644 --- a/RevenueCatUI/View+PresentPaywall.swift +++ b/RevenueCatUI/View+PresentPaywall.swift @@ -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 { @@ -59,6 +60,7 @@ extension View { .contains(requiredEntitlementIdentifier) }, purchaseCompleted: purchaseCompleted, + purchaseCancelled: purchaseCancelled, restoreCompleted: restoreCompleted, onDismiss: onDismiss ) @@ -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") @@ -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 { @@ -101,6 +106,7 @@ extension View { fonts: fonts, shouldDisplay: shouldDisplay, purchaseCompleted: purchaseCompleted, + purchaseCancelled: purchaseCancelled, restoreCompleted: restoreCompleted, onDismiss: onDismiss, customerInfoFetcher: { @@ -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 @@ -129,6 +136,7 @@ extension View { .modifier(PresentingPaywallModifier( shouldDisplay: shouldDisplay, purchaseCompleted: purchaseCompleted, + purchaseCancelled: purchaseCancelled, restoreCompleted: restoreCompleted, onDismiss: onDismiss, offering: offering, @@ -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)? @@ -180,6 +189,9 @@ private struct PresentingPaywallModifier: ViewModifier { .onPurchaseCompleted { self.purchaseCompleted?($0) } + .onPurchaseCancelled { + self.purchaseCancelled?() + } .onRestoreCompleted { customerInfo in self.restoreCompleted?(customerInfo) diff --git a/RevenueCatUI/View+PurchaseRestoreCompleted.swift b/RevenueCatUI/View+PurchaseRestoreCompleted.swift index e528e9bd2a..f7ac135e56 100644 --- a/RevenueCatUI/View+PurchaseRestoreCompleted.swift +++ b/RevenueCatUI/View+PurchaseRestoreCompleted.swift @@ -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 { @@ -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: @@ -129,7 +147,7 @@ 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) } } @@ -137,6 +155,26 @@ private struct OnPurchaseCompletedModifier: ViewModifier { } +@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 { diff --git a/Tests/APITesters/RevenueCatUIAPITester/SwiftAPITester/PaywallViewAPI.swift b/Tests/APITesters/RevenueCatUIAPITester/SwiftAPITester/PaywallViewAPI.swift index bd2303a8fc..9f6caa9b98 100644 --- a/Tests/APITesters/RevenueCatUIAPITester/SwiftAPITester/PaywallViewAPI.swift +++ b/Tests/APITesters/RevenueCatUIAPITester/SwiftAPITester/PaywallViewAPI.swift @@ -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 { @@ -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 } @@ -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 @@ -140,6 +157,7 @@ struct App: View { Text("") .onPurchaseCompleted(self.purchaseOrRestoreCompleted) .onPurchaseCompleted(self.purchaseCompleted) + .onPurchaseCancelled(self.purchaseCancelled) .onRestoreCompleted(self.purchaseOrRestoreCompleted) } diff --git a/Tests/APITesters/RevenueCatUIAPITester/SwiftAPITester/PaywallViewControllerAPI.swift b/Tests/APITesters/RevenueCatUIAPITester/SwiftAPITester/PaywallViewControllerAPI.swift index ee8a392fa8..21bcddf060 100644 --- a/Tests/APITesters/RevenueCatUIAPITester/SwiftAPITester/PaywallViewControllerAPI.swift +++ b/Tests/APITesters/RevenueCatUIAPITester/SwiftAPITester/PaywallViewControllerAPI.swift @@ -45,6 +45,8 @@ final class Delegate: PaywallViewControllerDelegate { didFinishPurchasingWith customerInfo: CustomerInfo, transaction: StoreTransaction?) {} + func paywallViewControllerDidCancelPurchase(_ controller: PaywallViewController) {} + func paywallViewController(_ controller: PaywallViewController, didFinishRestoringWith customerInfo: CustomerInfo) {} diff --git a/Tests/RevenueCatUITests/PurchaseCompletedHandlerTests.swift b/Tests/RevenueCatUITests/PurchaseCompletedHandlerTests.swift index f72db0f20a..556f141ea4 100644 --- a/Tests/RevenueCatUITests/PurchaseCompletedHandlerTests.swift +++ b/Tests/RevenueCatUITests/PurchaseCompletedHandlerTests.swift @@ -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 } @@ -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? diff --git a/Tests/RevenueCatUITests/Purchasing/PurchaseHandlerTests.swift b/Tests/RevenueCatUITests/Purchasing/PurchaseHandlerTests.swift index e6a578859b..fd78487537 100644 --- a/Tests/RevenueCatUITests/Purchasing/PurchaseHandlerTests.swift +++ b/Tests/RevenueCatUITests/Purchasing/PurchaseHandlerTests.swift @@ -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 @@ -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 } diff --git a/Tests/TestingApps/PaywallsTester/PaywallsTester/Views/UpsellView.swift b/Tests/TestingApps/PaywallsTester/PaywallsTester/Views/UpsellView.swift index 7f81c4325c..c9a2f59d8c 100644 --- a/Tests/TestingApps/PaywallsTester/PaywallsTester/Views/UpsellView.swift +++ b/Tests/TestingApps/PaywallsTester/PaywallsTester/Views/UpsellView.swift @@ -22,6 +22,12 @@ struct UpsellView: View { .padding() .presentPaywallIfNeeded( requiredEntitlementIdentifier: Configuration.entitlement, + purchaseCompleted: { _ in + print("Purchase completed") + }, + purchaseCancelled: { + print("Purchase cancelled") + }, onDismiss: { print("Paywall dismissed") }