Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Paywalls: new .onPurchaseStarted(package) modifier #3693

Merged
merged 4 commits into from
Feb 21, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions RevenueCatUI/PaywallView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -301,8 +301,8 @@ struct LoadedOfferingPaywallView: View {
var body: some View {
// Note: preferences need to be applied after `.toolbar` call
self.content
.preference(key: PurchasedInProgressPreferenceKey.self,
value: self.purchaseHandler.purchaseInProgress)
.preference(key: PurchaseInProgressPreferenceKey.self,
value: self.purchaseHandler.packageBeingPurchased)
.preference(key: PurchasedResultPreferenceKey.self,
value: .init(data: self.purchaseHandler.purchaseResult))
.preference(key: RestoredCustomerInfoPreferenceKey.self,
Expand Down
12 changes: 6 additions & 6 deletions RevenueCatUI/Purchasing/PurchaseHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ final class PurchaseHandler: ObservableObject {

/// Whether a purchase is currently in progress
@Published
fileprivate(set) var purchaseInProgress: Bool = false
fileprivate(set) var packageBeingPurchased: Package?

/// Whether a purchase or restore is currently in progress
@Published
Expand Down Expand Up @@ -84,13 +84,13 @@ extension PurchaseHandler {

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

self.startAction()
defer {
self.purchaseInProgress = false
self.packageBeingPurchased = nil
self.actionInProgress = false
}

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

@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
struct PurchasedInProgressPreferenceKey: PreferenceKey {
struct PurchaseInProgressPreferenceKey: PreferenceKey {

static var defaultValue: Bool = false
static var defaultValue: Package?

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

Expand Down
10 changes: 8 additions & 2 deletions RevenueCatUI/UIKit/PaywallViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,11 @@ public protocol PaywallViewControllerDelegate: AnyObject {
@objc(paywallViewControllerDidStartPurchase:)
optional func paywallViewControllerDidStartPurchase(_ controller: PaywallViewController)

/// Notifies that a purchase has started in a ``PaywallViewController``.
@objc(paywallViewController:didStartPurchaseWithPackage:)
optional func paywallViewController(_ controller: PaywallViewController,
didStartPurchaseWith package: Package)

/// Notifies that a purchase has completed in a ``PaywallViewController``.
@objc(paywallViewController:didFinishPurchasingWithCustomerInfo:)
optional func paywallViewController(_ controller: PaywallViewController,
Expand Down Expand Up @@ -238,9 +243,10 @@ private extension PaywallViewController {
func createHostingController() -> UIHostingController<PaywallContainerView> {
let container = PaywallContainerView(
configuration: self.configuration,
purchaseStarted: { [weak self] in
purchaseStarted: { [weak self] package in
guard let self else { return }
self.delegate?.paywallViewControllerDidStartPurchase?(self)
self.delegate?.paywallViewController?(self, didStartPurchaseWith: package)
},
purchaseCompleted: { [weak self] transaction, customerInfo in
guard let self else { return }
Expand Down Expand Up @@ -292,7 +298,7 @@ private struct PaywallContainerView: View {

var configuration: PaywallViewConfiguration

let purchaseStarted: PurchaseStartedHandler
let purchaseStarted: PurchaseOfPackageStartedHandler
let purchaseCompleted: PurchaseCompletedHandler
let purchaseCancelled: PurchaseCancelledHandler
let restoreCompleted: PurchaseOrRestoreCompletedHandler
Expand Down
159 changes: 152 additions & 7 deletions RevenueCatUI/View+PresentPaywall.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,15 @@ extension PaywallPresentationMode {

}

// swiftlint:disable file_length
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
@available(macOS, unavailable, message: "RevenueCatUI does not support macOS yet")
@available(tvOS, unavailable, message: "RevenueCatUI does not support tvOS yet")
extension View {

typealias CustomerInfoFetcher = @Sendable () async throws -> CustomerInfo

// swiftlint:disable line_length
/// Presents a ``PaywallView`` if the given entitlement identifier is not active
/// in the current environment for the current `CustomerInfo`.
/// ```swift
Expand All @@ -65,18 +67,77 @@ extension View {
/// [Documentation](https://rev.cat/paywalls)
///
/// - Tag: presentPaywallIfNeeded
@available(iOS, deprecated: 1, renamed: "presentPaywallIfNeeded(requiredEntitlementIdentifier:offering:fonts:presentationMode:purchaseStarted:purchaseCompleted:purchaseCancelled:restoreStarted:restoreCompleted:purchaseFailure:restoreFailure:onDismiss:)")
@available(tvOS, deprecated: 1, renamed: "presentPaywallIfNeeded(requiredEntitlementIdentifier:offering:fonts:presentationMode:purchaseStarted:purchaseCompleted:purchaseCancelled:restoreStarted:restoreCompleted:purchaseFailure:restoreFailure:onDismiss:)")
@available(watchOS, deprecated: 1, renamed: "presentPaywallIfNeeded(requiredEntitlementIdentifier:offering:fonts:presentationMode:purchaseStarted:purchaseCompleted:purchaseCancelled:restoreStarted:restoreCompleted:purchaseFailure:restoreFailure:onDismiss:)")
@available(macOS, deprecated: 1, renamed: "presentPaywallIfNeeded(requiredEntitlementIdentifier:offering:fonts:presentationMode:purchaseStarted:purchaseCompleted:purchaseCancelled:restoreStarted:restoreCompleted:purchaseFailure:restoreFailure:onDismiss:)")
@available(macCatalyst, deprecated: 1, renamed: "presentPaywallIfNeeded(requiredEntitlementIdentifier:offering:fonts:presentationMode:purchaseStarted:purchaseCompleted:purchaseCancelled:restoreStarted:restoreCompleted:purchaseFailure:restoreFailure:onDismiss:)")
// swiftlint:enable line_length
public func presentPaywallIfNeeded(
requiredEntitlementIdentifier: String,
offering: Offering? = nil,
fonts: PaywallFontProvider = DefaultPaywallFontProvider(),
presentationMode: PaywallPresentationMode = .default,
purchaseStarted: PurchaseStartedHandler? = nil,
purchaseStarted: @escaping PurchaseStartedHandler,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Made this non nullable so the function wouldn't collide if someone was only passing requiredEntitlementIdentifier. I also had to made it @escaping. Is that considered a breaking change?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@vegaro I'm not certain if the @escaping is breaking changing (I'd have to experiment) but making this non-nullable is a breaking change, isn't it? 😬

If somebody was using this function without purchaseStarted, it will now require them to have purchaseStarted?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did the API tester for this break? That would really let us know, wouldn't it? 🤷‍♂️

Copy link
Contributor Author

@vegaro vegaro Feb 21, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It didn't fail, that's why I am not sure 🤷‍♂️

If somebody was using this function without purchaseStarted, it will now require them to have purchaseStarted?

If they were not passing it, it will use the new non-deprecated function automatically (that one has it as nullable)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ahhh, okay! That makes sense then 😊 I think this should be good then!

purchaseCompleted: PurchaseOrRestoreCompletedHandler? = nil,
purchaseCancelled: PurchaseCancelledHandler? = nil,
restoreCompleted: PurchaseOrRestoreCompletedHandler? = nil,
purchaseFailure: PurchaseFailureHandler? = nil,
restoreFailure: PurchaseFailureHandler? = nil,
onDismiss: (() -> Void)? = nil
) -> some View {
return self.presentPaywallIfNeeded(
requiredEntitlementIdentifier: requiredEntitlementIdentifier,
offering: offering,
fonts: fonts,
presentationMode: presentationMode,
purchaseStarted: { _ in
purchaseStarted()
},
purchaseCompleted: purchaseCompleted,
purchaseCancelled: purchaseCancelled,
restoreStarted: nil,
restoreCompleted: restoreCompleted,
purchaseFailure: purchaseFailure,
restoreFailure: restoreFailure,
onDismiss: onDismiss
)
}

/// Presents a ``PaywallView`` if the given entitlement identifier is not active
/// in the current environment for the current `CustomerInfo`.
/// ```swift
/// var body: some View {
/// YourApp()
/// .presentPaywallIfNeeded(requiredEntitlementIdentifier: "pro")
/// }
/// ```
/// - Note: If loading the `CustomerInfo` fails (for example, if Internet is offline),
/// the paywall won't be displayed.
///
/// - Parameter offering: The `Offering` containing the desired `PaywallData` to display.
/// If `nil` (the default), `Offerings.current` will be used. Note that specifying this parameter means
/// that it will ignore the offering configured in an active experiment.
/// - Parameter fonts: An optional ``PaywallFontProvider``.
/// - Parameter presentationMode: The desired presentation mode of the paywall. Defaults to `.sheet`.
///
/// ### Related Articles
/// [Documentation](https://rev.cat/paywalls)
///
/// - Tag: presentPaywallIfNeeded
public func presentPaywallIfNeeded(
requiredEntitlementIdentifier: String,
offering: Offering? = nil,
fonts: PaywallFontProvider = DefaultPaywallFontProvider(),
presentationMode: PaywallPresentationMode = .default,
purchaseStarted: PurchaseOfPackageStartedHandler? = nil,
purchaseCompleted: PurchaseOrRestoreCompletedHandler? = nil,
purchaseCancelled: PurchaseCancelledHandler? = nil,
restoreStarted: RestoreStartedHandler? = nil,
restoreCompleted: PurchaseOrRestoreCompletedHandler? = nil,
purchaseFailure: PurchaseFailureHandler? = nil,
restoreFailure: PurchaseFailureHandler? = nil,
onDismiss: (() -> Void)? = nil
) -> some View {
return self.presentPaywallIfNeeded(
offering: offering,
Expand All @@ -98,6 +159,7 @@ extension View {
)
}

// swiftlint:disable line_length
/// Presents a ``PaywallView`` based a given condition.
/// Example:
/// ```swift
Expand Down Expand Up @@ -134,12 +196,18 @@ extension View {
///
/// ### Related Articles
/// [Documentation](https://rev.cat/paywalls)
@available(iOS, deprecated: 1, renamed: "presentPaywallIfNeeded(offering:fonts:presentationMode:shouldDisplay:purchaseStarted:purchaseCompleted:purchaseCancelled:restoreStarted:restoreCompleted:purchaseFailure:restoreFailure:onDismiss:)")
@available(tvOS, deprecated: 1, renamed: "presentPaywallIfNeeded(offering:fonts:presentationMode:shouldDisplay:purchaseStarted:purchaseCompleted:purchaseCancelled:restoreStarted:restoreCompleted:purchaseFailure:restoreFailure:onDismiss:)")
@available(watchOS, deprecated: 1, renamed: "presentPaywallIfNeeded(offering:fonts:presentationMode:shouldDisplay:purchaseStarted:purchaseCompleted:purchaseCancelled:restoreStarted:restoreCompleted:purchaseFailure:restoreFailure:onDismiss:)")
@available(macOS, deprecated: 1, renamed: "presentPaywallIfNeeded(offering:fonts:presentationMode:shouldDisplay:purchaseStarted:purchaseCompleted:purchaseCancelled:restoreStarted:restoreCompleted:purchaseFailure:restoreFailure:onDismiss:)")
@available(macCatalyst, deprecated: 1, renamed: "presentPaywallIfNeeded(offering:fonts:presentationMode:shouldDisplay:purchaseStarted:purchaseCompleted:purchaseCancelled:restoreStarted:restoreCompleted:purchaseFailure:restoreFailure:onDismiss:)")
// swiftlint:enable line_length
public func presentPaywallIfNeeded(
offering: Offering? = nil,
fonts: PaywallFontProvider = DefaultPaywallFontProvider(),
presentationMode: PaywallPresentationMode = .default,
shouldDisplay: @escaping @Sendable (CustomerInfo) -> Bool,
purchaseStarted: PurchaseStartedHandler? = nil,
purchaseStarted: @escaping PurchaseStartedHandler,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here. If someone was not passing anything or passing let's say just offering, it would collide with the new version

purchaseCompleted: PurchaseOrRestoreCompletedHandler? = nil,
purchaseCancelled: PurchaseCancelledHandler? = nil,
restoreCompleted: PurchaseOrRestoreCompletedHandler? = nil,
Expand All @@ -152,7 +220,9 @@ extension View {
fonts: fonts,
presentationMode: presentationMode,
shouldDisplay: shouldDisplay,
purchaseStarted: purchaseStarted,
purchaseStarted: { _ in
purchaseStarted()
},
purchaseCompleted: purchaseCompleted,
purchaseCancelled: purchaseCancelled,
restoreStarted: nil,
Expand All @@ -170,6 +240,81 @@ extension View {
)
}

/// Presents a ``PaywallView`` based a given condition.
/// Example:
/// ```swift
/// var body: some View {
/// YourApp()
/// .presentPaywallIfNeeded {
/// !$0.entitlements.active.keys.contains("entitlement_identifier")
/// } purchaseStarted: { package in
/// print("Purchase started \(package)")
/// } purchaseCompleted: { customerInfo in
/// print("Customer info unlocked entitlement: \(customerInfo.entitlements)")
/// } purchaseCancelled: {
/// print("Purchase was cancelled")
/// } restoreStarted: {
/// print("Restore started")
/// } restoreCompleted: { customerInfo in
/// // If `entitlement_identifier` is active, paywall will dismiss automatically.
/// print("Purchases restored")
/// } purchaseFailure: { error in
/// print("Error purchasing: \(error)")
/// } restoreFailure: { error in
/// print("Error restoring purchases: \(error)")
/// } onDismiss: {
/// print("Paywall was dismissed either manually or automatically after a purchase.")
/// }
/// }
/// ```
/// - Note: If loading the `CustomerInfo` fails (for example, if Internet is offline),
/// the paywall won't be displayed.
///
/// - Parameter offering: The `Offering` containing the desired `PaywallData` to display.
/// If `nil` (the default), `Offerings.current` will be used. Note that specifying this parameter means
/// that it will ignore the offering configured in an active experiment.
/// - Parameter fonts: An optional ``PaywallFontProvider``.
/// - Parameter presentationMode: The desired presentation mode of the paywall. Defaults to `.sheet`.
///
/// ### Related Articles
/// [Documentation](https://rev.cat/paywalls)
public func presentPaywallIfNeeded(
offering: Offering? = nil,
fonts: PaywallFontProvider = DefaultPaywallFontProvider(),
presentationMode: PaywallPresentationMode = .default,
shouldDisplay: @escaping @Sendable (CustomerInfo) -> Bool,
purchaseStarted: PurchaseOfPackageStartedHandler? = nil,
purchaseCompleted: PurchaseOrRestoreCompletedHandler? = nil,
purchaseCancelled: PurchaseCancelledHandler? = nil,
restoreStarted: RestoreStartedHandler? = nil,
restoreCompleted: PurchaseOrRestoreCompletedHandler? = nil,
purchaseFailure: PurchaseFailureHandler? = nil,
restoreFailure: PurchaseFailureHandler? = nil,
onDismiss: (() -> Void)? = nil
) -> some View {
return self.presentPaywallIfNeeded(
offering: offering,
fonts: fonts,
presentationMode: presentationMode,
shouldDisplay: shouldDisplay,
purchaseStarted: purchaseStarted,
purchaseCompleted: purchaseCompleted,
purchaseCancelled: purchaseCancelled,
restoreStarted: restoreStarted,
restoreCompleted: restoreCompleted,
purchaseFailure: purchaseFailure,
restoreFailure: restoreFailure,
onDismiss: onDismiss,
customerInfoFetcher: {
guard Purchases.isConfigured else {
throw PaywallError.purchasesNotConfigured
}

return try await Purchases.shared.customerInfo()
}
)
}

// Visible overload for tests
func presentPaywallIfNeeded(
offering: Offering? = nil,
Expand All @@ -178,7 +323,7 @@ extension View {
purchaseHandler: PurchaseHandler? = nil,
presentationMode: PaywallPresentationMode = .default,
shouldDisplay: @escaping @Sendable (CustomerInfo) -> Bool,
purchaseStarted: PurchaseStartedHandler? = nil,
purchaseStarted: PurchaseOfPackageStartedHandler? = nil,
purchaseCompleted: PurchaseOrRestoreCompletedHandler? = nil,
purchaseCancelled: PurchaseCancelledHandler? = nil,
restoreStarted: RestoreStartedHandler? = nil,
Expand Down Expand Up @@ -222,7 +367,7 @@ private struct PresentingPaywallModifier: ViewModifier {

var shouldDisplay: @Sendable (CustomerInfo) -> Bool
var presentationMode: PaywallPresentationMode
var purchaseStarted: PurchaseStartedHandler?
var purchaseStarted: PurchaseOfPackageStartedHandler?
var purchaseCompleted: PurchaseOrRestoreCompletedHandler?
var purchaseCancelled: PurchaseCancelledHandler?
var restoreCompleted: PurchaseOrRestoreCompletedHandler?
Expand All @@ -240,7 +385,7 @@ private struct PresentingPaywallModifier: ViewModifier {
init(
shouldDisplay: @escaping @Sendable (CustomerInfo) -> Bool,
presentationMode: PaywallPresentationMode,
purchaseStarted: PurchaseStartedHandler?,
purchaseStarted: PurchaseOfPackageStartedHandler?,
purchaseCompleted: PurchaseOrRestoreCompletedHandler?,
purchaseCancelled: PurchaseCancelledHandler?,
restoreCompleted: PurchaseOrRestoreCompletedHandler?,
Expand Down Expand Up @@ -319,7 +464,7 @@ private struct PresentingPaywallModifier: ViewModifier {
)
)
.onPurchaseStarted {
self.purchaseStarted?()
self.purchaseStarted?($0)
}
.onPurchaseCompleted {
self.purchaseCompleted?($0)
Expand Down
Loading