Commit Graph
33 Commits
Author SHA1 Message Date
Josh Holtz 28aa6765ac Add activity indicator to restore purchases button behavior (#4848)
* Add activity indicator to restore purchases

* Start of smarter indicatoring

* Fixed

* Don't add progress indicators to all the things

* Fix pod lint
2025-03-04 09:40:31 -06:00
41d3541451 Paywalls with custom purchase and restore logic handlers (#3973)
## tl;dr

PaywallView has a new constructor that takes `performPurchase` and
`performRestore` blocks, which are called to preform purchasing/restore
directly by the customer's app when the purchase/restore buttons are
tapped on the paywall. This makes it possible to use RC Paywalls when
`Purchases` has been configured `.with(purchasesAreCompletedBy:
.myApp)`.

Example usage:
```swift
PaywallView(performPurchase: {
    var userCancelled = false
    var error: Error?
    
    // use StoreKit to perform purchase

    return (userCancelled: userCancelled, error: error)
}, performRestore: {
    var success = false
    var error: Error?

    // use StoreKit to perform restore

    return (success: success, error: error)
})
```

## Description

When a `PaywallView` is constructed, a new `PurchaseHandler` is created.
The `PurchaseHandler` (an internal RevenueCatUI class) is owned by the
`PaywallView`, and it is responsible for executing new purchases and
restores.

When a `PaywallView` is constructed **without** `performPurchase` and
`performRestore` blocks, the `PaywallView` creates a `PurchaseHandler`
capable of preforming purchases using RevenueCat. When a `PaywallView`
is constructed with `performPurchase` and `performRestore` blocks, it
can also make purchases using the customer-supplied closures.

The `PurchaseHandler` is invoked when the user taps the
`PurchaseButton`, calling `purchaseHandler.purchase(package:
self.selectedPackage.content)`, which branches to either the internal or
external purchase code, as defined by `purchasesAreCompletedBy`:

```swift
@MainActor
func purchase(package: Package) async throws {
    switch self.purchases.purchasesAreCompletedBy {
    case .revenueCat:
        try await performPurchase(package: package)
    case .myApp:
        try await performExternalPurchaseLogic(package: package)
    }
}
```


Purchase and Restore blocks can also be assigned for paywall footers:

```swift
MyAppDefinedPaywall()
    .paywallFooter(myAppPurchaseLogic: MyAppPurchaseLogic(performPurchase: { packageToPurchase in
        var userCancelled = false
        var error: Error?

        // use StoreKit to perform purchase

        return (userCancelled: userCancelled, error: error)
    }, performRestore: {
        var success = false
        var error: Error?

        // use StoreKit to perform restore

        return (success: success, error: error)
    }))
 ```

and via `.presentPaywallIfNeeded`:

```swift
MyAppDefinedPaywal()
.presentPaywallIfNeeded(requiredEntitlementIdentifier: "test",
myAppPurchaseLogic: MyAppPurchaseLogic(performPurchase: {
packageToPurchase in
        return (userCancelled: false, error: nil)
    }, performRestore: {
        return (success: true, error: nil)
    }))
```



## Notes

### Testing

A lot needs to be mocked, the purchase/restore blocks that are passed in need to be assigned directly to the `PurchaseHandler`, which is then passed in as part of a `PaywallConfiguration`, both of which are normally constructed internally in the PaywallView constructor.

```swift
    func testHandleExternalRestoreWithPurchaaseHandlers() throws {
        var completed = false
        var customRestoreCodeExecuted = false

        let purchasHandler = Self.externalPurchaseHandler { _ in
            return (userCancelled: true, error: nil)
        } performRestore: {
            customRestoreCodeExecuted = true
            return (success: true, error: nil)
        }

let config = PaywallViewConfiguration(purchaseHandler: purchasHandler)

        try PaywallView(configuration: config).addToHierarchy()

        Task {
            _ = try await purchasHandler.restorePurchases()
            completed = true
        }

        expect(completed).toEventually(beTrue())
        expect(customRestoreCodeExecuted) == true
    }
```

### Error Handling

For the external code blocks to be called, `purchasesAreCompletedBy` needs to be set to `.myApp` AND the blocks need to be defined. So we need to handle cases when these are not consistent:

1. If someone configures purchasesAreCompletedBy to `.myApp`, and then displays a PaywallView **without** purchase/restore handlers, the SDK will:
* Log an error when the PaywallView is constructed
* When the PaywallView is displayed, replace it with a big red banner when in debug mode, fatalError() in release mode.
* If you purchase directly via the purchase handler (which is an internal class but this is done for testing), it will throw.

2. If someone configures purchases to use `.revenueCat`, and then displays a PaywallView **with** purchase/restore handler, the SDK will:

* Log a warning when the PaywallView is constructed

Rationale:
In case 1, there could be no (acceptable) way to make a purchase, so this is very bad and it needs to be dealt with.

In case 2, they've over-specified the paywall, and while this might be confusing (why isn't my code being called??), it's not as problematic, and we want to make it easy for people to switch from using their purchase logic to our purchase logic.

These checks are made in PaywallView.swift, method `checkForConfigurationConsitency()`.

### UIKit

Not yet supported, will add in a follow-up PR, unlikely to be complicated.

### Paywall Footer and Paywall If Needed
The `performPurchase` and `performRestore` parameters for the paywall footer are contained in a struct rather than as loose parameters of closure types, because doing the latter would create a very poor experience with regards to code completion, where Xcode will always offer complete your code as a trailing closure, but will always use the first closure where the function signature matches.

The following illustrates the problem of using loose closure parameters:

The trailing closure that Xcode auto-creates here does _not_ get called for `performPurchase`, but rather `purchaseStarted`, because it also has a `package` as its input parameter, and comes first in the parameter list 😱.

https://github.com/RevenueCat/purchases-ios/assets/109382862/1f809e7c-b661-4844-89e3-fd846a029531

This is the problematic function signature:

```swift
public func paywallFooter(
        offering: Offering,
        condensed: Bool = false,
        fonts: PaywallFontProvider = DefaultPaywallFontProvider(),
        purchaseStarted: PurchaseOfPackageStartedHandler? = nil,
        purchaseCompleted: PurchaseOrRestoreCompletedHandler? = nil,
        purchaseCancelled: PurchaseCancelledHandler? = nil,
        restoreStarted: RestoreStartedHandler? = nil,
        restoreCompleted: PurchaseOrRestoreCompletedHandler? = nil,
        purchaseFailure: PurchaseFailureHandler? = nil,
        restoreFailure: PurchaseFailureHandler? = nil,
        performPurchase: PerformPurchase? = nil,
        performRestore: PerformRestore? = nil
    ) -> some View
 ```

To fix this, get rid of the two new parameters: 
```swift
        performPurchase: PerformPurchase? = nil,
        performRestore: PerformRestore? = nil
```
 
and embed them in a struct:

```swift
public struct MyAppPurchaseLogic {
    public let performPurchase: PerformPurchase
    public let performRestore: PerformRestore

    public init(performPurchase: @escaping PerformPurchase, performRestore: @escaping PerformRestore) {
        self.performPurchase = performPurchase
        self.performRestore = performRestore
    }
}
```

which we use as a last parameter:

```swift
        myAppPurchaseLogic: MyAppPurchaseLogic? = nil
```

 Now the code completes as so:


https://github.com/RevenueCat/purchases-ios/assets/109382862/332ac42d-ccba-42bc-bfc3-702d7870c99c

The `MyAppPurchaseLogic` doesn't complete in perfectly, but once you
initialize one of those it goes nicely the rest of the way, including
the error messages that instruct you on what you need to return.

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Josh Holtz <me@joshholtz.com>
Co-authored-by: Cesar de la Vega <cesarvegaro@gmail.com>
Co-authored-by: RevenueCat Git Bot <72824662+RCGitBot@users.noreply.github.com>
Co-authored-by: Toni Rico <toni.rico.diez@revenuecat.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Will Taylor <wtaylor151@gmail.com>
Co-authored-by: Andy Boedo <andresboedo@gmail.com>
2024-07-05 12:05:44 -07:00
NachoSoto 2bcd0ce328 5.0.0 changes: support SK2 by default 2024-07-01 14:37:21 -05:00
James BorthwickandCesar de la Vega 9e8b40c322 finishTransactions/ObserverMode -> PurchasesAreCompletedBy (#3947)
This PR adds:

* `purchasesAreCompletedBy`

which replaces and deprecates public instances of:
* `observerMode`
* `finishTransactions`

Internal references to the above will be removed in a subsequent PR.

---------

Co-authored-by: Cesar de la Vega <cesarvegaro@gmail.com>
2024-06-18 10:50:04 -07:00
James Borthwick 478dd26e64 Paywalls Tester: Enable Example Paywalls in Release Mode (#3885)
The paywalls examples weren't showing up when compiled in release mode.
Enabling it meant making a bunch of more internal things public via the
preprocessing script. `TestData` is no longer `#ifdef DEBUG`ed out, but
most of its data is still `#ifdef DEBUG`, and when it's compiled with an
`internal` modifier and thus nothing uses it, I'd expect what remains to
be stripped away during linking.
2024-05-15 12:06:05 -07:00
James Borthwick 62a8c42a10 Preprocessor to make select constructs public (#3880)
This PR allows the Paywalls Tester app to access some items internal to
purchases-ios by temporarily marking them public during complication.

Items that need to be public have been annotated with
`\\@PublicForExternalTesting`.

The Paywalls Tester project runs a script, Preprocessor.sh, as a
pre-action to add `public` to all classes/structs/enums/functions/inits
that have been annotated.

After compilation it runs a second script, Postprocessor.sh, as a
post-action to undo these changes so that they don't accidentally get
checked in.

The first script is run as a scheme pre-action rather than as a run
script build phase, because when run as a build phase the changes to the
files aren't picked up until the next compilation attempt.

<img width="669" alt="image"
src="https://github.com/RevenueCat/purchases-ios/assets/109382862/0b0bacdb-79de-441d-8d61-2a5dd03b1b53">

It also changes the archive step to be built with a release
configuration, and removes the use of `@testable import` for debug
builds.

resolves PWL-459
2024-05-09 11:37:11 -07:00
Cesar de la Vega 8bc8a013dc PaywallViewController: option to not automatically dismiss and add onRequestedDismissal (#3738)
Please read
https://github.com/RevenueCat/react-native-purchases/pull/913
2024-03-13 08:41:34 -05:00
Cesar de la Vega 3442d40e5a Paywalls: new .onPurchaseStarted(package) modifier (#3693)
Similar to #3627.

This API is available in:
- `PaywallView()`
- `.presentPaywallIfNeeded`
- `.paywallFooter`
- `PaywallViewControllerDelegate`
2024-02-21 18:52:57 +01:00
Cesar de la VegaandMark Villacampa b7ecce223f Paywalls: new .onRestoreStarted modifier (#3694)
Similar to #3627.

This API is available in:
- `PaywallView()`
- `.presentPaywallIfNeeded`
- `.paywallFooter`
- `PaywallViewControllerDelegate`

---------

Co-authored-by: Mark Villacampa <m@markvillacampa.com>
2024-02-19 17:21:31 +01:00
NachoSoto 4735b6b466 Paywalls: new .onPurchaseStarted modifier (#3627)
Similar to #3622.

This API is available in:
- `PaywallView()`
- `.presentPaywallIfNeeded`
- `.paywallFooter`
- `PaywallViewControllerDelegate`
2024-02-02 16:37:49 -08:00
NachoSoto fc566cd850 Paywalls: new .onPurchaseFailure and .onRestoreFailure modifiers (#3622) 2024-02-01 11:16:49 -08:00
NachoSoto 9d1772a85f Paywalls: .onRestoreCompleted is invoked after the restore dialog is dismissed (#3620)
Fixes #3617.

This allows users to handle that notification only once the user has
dismissed the "purchases restored" dialog.
2024-02-01 08:44:05 -08:00
NachoSoto 0098ee8fe0 Paywalls: new .onPurchaseCancelled and paywallViewControllerDidCancelPurchase: (#3578)
This allows apps to be able to receive notifications when users cancel a
purchase.
2024-01-17 09:08:54 -08:00
NachoSotoandAndy Boedo af08736775 RevenueCatUI: added support to other deployment targets (#3392)
Follow up to #3378.

This allows us to link both `RevenueCat` and `RevenueCatUI` from
`PurchasesHybridCommon` supporting the same platforms.
The actual types might not be available in some platforms / versions,
but we can detect that at compile time.

See
https://app.circleci.com/pipelines/github/RevenueCat/purchases-hybrid-common/2564/workflows/ea6a4e24-e1eb-4c7a-a930-173945732999/jobs/10352

---------

Co-authored-by: Andy Boedo <andresboedo@gmail.com>
2023-11-08 07:35:19 -08:00
38b4e3b411 Paywalls: add identifier to events (#3332)
### Motivation

Send a unique `id` on each paywall event

### Description

- Added `id: ID?` to `PaywallEvent.Data`
- Optional so its backwards compatible with events recorded before this
change (see actual tests from below)
- Add `withNewID()` function that will mutate the `id` when tracked for
cancel and close

#### Events recored before `id` changes but posted with `id` changes

<img width="433" alt="Screenshot 2023-10-25 at 9 33 27 AM"
src="https://github.com/RevenueCat/purchases-ios/assets/401294/b6c77f0c-7429-4638-a4ba-7d6068c80cb7">

#### Events recorded and posted with `id` changes

<img width="479" alt="Screenshot 2023-10-25 at 9 37 53 AM"
src="https://github.com/RevenueCat/purchases-ios/assets/401294/8cae5329-2f31-4f03-8997-69dcee609e1f">

---------

Co-authored-by: NachoSoto <ignaciosoto90@gmail.com>
Co-authored-by: RevenueCat Git Bot <72824662+RCGitBot@users.noreply.github.com>
Co-authored-by: Distiller <distiller@static.38.39.183.26.cyberlynk.net>
Co-authored-by: Distiller <distiller@static.38.39.184.242.cyberlynk.net>
Co-authored-by: distiller <distiller@static.38.23.41.34.cyberlynk.net>
2023-10-25 20:26:50 -05:00
32dc5ed715 Paywalls: create new event session when paywall appears (#3330)
### Motivation

Prevent `paywall_impression` and `paywall_close` be recorded multiple
times for the same session (which could happen if same `PaywallView` is
displayed multiple times)

### Description

- `PaywallView`'s `onAppear` will always create/update the session
- `PurchaseHandler`
- `trackPaywallImpression()` will store the `PaywallEvent.Data` for
cancel and close to use
- `trackPaywallClose()` will set `eventData` to `nil` so that close can
only be called once

---------

Co-authored-by: NachoSoto <ignaciosoto90@gmail.com>
Co-authored-by: NachoSoto <NachoSoto@users.noreply.github.com>
2023-10-24 15:21:19 -05:00
NachoSoto 4a9b9e4bf8 Paywalls: new .onPurchaseCompleted overload with StoreTransaction (#3323) 2023-10-23 20:43:41 -07:00
NachoSoto 5ba291105f Paywalls: don't display "Purchases restored successfully" if nothings was restored (#3233)
We can improve the logic here, but this is a simple improvement for the
obvious case where nothing was actually restored.
2023-09-20 15:19:23 -07:00
NachoSoto 293836aac8 Paywalls: renamed PaywallEvent.view to .impression (#3212)
Suggested by @aboedo, makes this less ambiguous with `PaywallView`.
2023-09-14 18:28:11 -07:00
NachoSoto ac74b74172 Paywalls: refactored PurchaseHandler extracting protocol (#3196)
As suggested by @aboedo in
https://github.com/RevenueCat/purchases-ios/pull/3164#discussion_r1323603537
2023-09-14 18:28:11 -07:00
NachoSoto 6c345a514f Paywalls: send events to Purchases (#3164)
Last part, follow up to #3160.

This now makes `PaywallView` track events through the `RevenueCat` SDK.
2023-09-14 18:28:11 -07:00
NachoSoto 66ef49d02e Paywalls: new onRestoreCompleted handler (#3190)
This is available on `.paywallFooter()`, `.presentPaywallIfNeeded`, and
as a `.onRestoreCompleted` modifier.
2023-09-14 18:28:11 -07:00
NachoSoto 9c354c02ec Paywalls: fixed data flow resulting in multiple PurchaseHandler instances (#3187)
This was breaking event notifications as well as
`OnPurchaseCompletedModifier`, because there were multiple
`PurchaseHandler` instances created.

The solution is to make `PaywallView` store these as `@StateObject`. In
order to do that, the type can't be optional, so I instead encoded the
"is configured" information inside.
2023-09-14 18:28:11 -07:00
NachoSoto 56de917d02 Paywalls: removed presentedPaywallViewMode (#3109)
We're going to track this in a different way in the future.
2023-09-14 18:28:11 -07:00
NachoSoto 2103b04762 Paywalls: added MIT license to all headers (#3084)
`Xcode` doesn't allow customizing the header (looks like maybe it worked
at some point but not anymore:
https://stackoverflow.com/questions/67614785/changing-the-default-header-comment-license-in-swift-package).

At least this adds the license to all the initial files in the package.
2023-09-14 18:28:11 -07:00
NachoSoto 8ba761a23f Paywalls: send presented PaywallViewMode with purchases (#2859)
I've moved `PaywallViewMode` from `RevenueCatUI` to `RevenueCat`. This
uses the same approach as #2645. This method is `public` so it can be
used from `RevenueCatUI`, but the documentation stays its purpose.

The behavior is covered with integration tests and a snapshot test.

Depends on https://github.com/RevenueCat/khepri/pull/6364.
2023-09-14 18:28:11 -07:00
NachoSoto 534a556e62 Paywalls: initial iOS 15 support (#2933)
`PaywallView` and `PaywallViewController` (along with the extensions)
still require iOS 16, but this paves the way to support iOS 15 down the
road if we need to.

### Main Changes:

- Changed all `@available` annotations
- Added CI tests
- Replaced `ViewThatFits` with the "biggest" version
- Because of that, `scrollableIfNeeded` is always scrollable
- Double `Regex` implementation (keeping the modern one), so once we
drop support for iOS 15 we can just keep the modern one.

### TODO: 
- [x] Add snapshots
- [x] Test paywalls
- [x] Updated APITester to verify this
2023-09-14 18:28:11 -07:00
NachoSoto 1235cc17a1 Paywalls: more unit tests for purchasing state (#2931)
Follow up to #2930.

Added `PresentIfNecessaryTests` and `PurchaseHandlerTests`.
This also removes the last source of "crashes" whenever `Purchases`
isn't configured, leading to errors instead.
2023-09-14 18:28:11 -07:00
NachoSoto bf32e02fa6 Paywalls: new .onPurchaseCompleted modifier (#2930)
This allows users to present a `PaywallView` and be notified of
purchasing events. Example:
```swift
VStack {
    YourViews()

    if !self.didPurchase {
        PaywallView()
    }
}
    .onPurchaseCompleted { customerInfo in
        print("Purchase completed: \(customerInfo.entitlements)")
        self.didPurchase = true
    }
```

Additionally, an explicit optional closure is added to
`.presentPaywallIfNecessary`:
```swift
YourApp()
    .presentPaywallIfNecessary {
        !$0.entitlements.active.keys.contains("entitlement_identifier")
    } purchaseCompleted: { customerInfo in
        print("Customer info unlocked entitlement: \(customerInfo.entitlements)")
    }
```

These are now tested through new unit tests.
2023-09-14 18:28:11 -07:00
NachoSoto 95d2c3ba4c Paywalls: moved purchasing state to PurchaseHandler (#2923)
This allows `PaywallView` to handle state handling during a purchase:
instead of the previous behavior where only `PurchaseButton` would
disable itself, now the entire `PaywallView` is disabled.

I extracted `PackageButtonStyle` so any template with multi-package
selection can get this same behavior: the package selection also becomes
disabled, as well as any other button (like restore purchases).

I've also cleaned up the templates since we no longer needed the
"Content" inside view, and they can be simplified with a single type.

By making `PurchaseHandler` `@MainActor` we also ensure that all these
state transitions lead to UI changes happening exclusively on the main
thread.
2023-09-14 18:28:11 -07:00
NachoSoto b09c617a31 Paywalls: added FooterView (#2850)
This adds 3 new features: restore purchases, ToS, and privacy policy.
All configurable.

![image](https://github.com/RevenueCat/purchases-ios/assets/685609/b38dadec-9fa1-4d48-876d-b53d0dda93d9)
2023-09-14 18:28:11 -07:00
NachoSoto 6e26b7ed50 Paywalls: extracted PurchaseButton (#2839)
Another small refactor, follow up to #2837.
Slowly taking implementation details from `Example1Template` to make it
easier to implement more templates.
2023-09-14 18:28:11 -07:00
NachoSoto 63f7bbceff Paywalls: added support for purchasing (#2812)
This adds a new `PurchaseHandler` type, which can be instantiated with
`Purchases` or with a mock implementation for previews. That gets
injected, and templates can now purchase and dismiss themselves.

I've improved the `SimpleApp` setup to better work with the lifetime of
purchases. Now it only displays the paywall if the user doesn't have an
entitlement.

I've also added some basic error handling to the new `AsyncButton`:
![Simulator Screenshot - iPhone 14 Pro - 2023-07-13 at 15 32
06](https://github.com/RevenueCat/purchases-ios/assets/685609/e8c2e6e3-f1e0-411f-9ca2-8a3cc3823a42)
2023-09-14 18:28:11 -07:00