Commit Graph
28 Commits
Author SHA1 Message Date
Josh Holtz 8f71dd9199 [Paywalls V2] Process variables in the text component (#4490)
* Start of processing variables in text

* Moved some files

* Fixed lint and added logic for zero decimal place

* Fix test

* Apply new PackageContext in package and better preview

* Fixed lint
2024-11-19 05:55:37 -06:00
Josh Holtz 1fa69d42f4 [Paywalls] Improve locale consistency (#4158)
⚠️ Waiting for #4188 to be merged first (because needs all the tests to
pass)

## Motivation

Fix cases where paywall was showing multiple languages at once and
random languages.

## Description

👉 For a demo of the problem and solution, look in Slack in the paywalls
or SDK channel for the latest Loom that I posted

### Flow of paywall localization logic (since it can be confusing)

1.
[PaywallView](https://github.com/RevenueCat/purchases-ios/pull/4158/files#diff-ea75fe7280803e15b975d702a16c6851ffc2bbed01b7b196bf0fb80d75b4b332)
starts with `Locale.current` but then calls
`offering.validatedPaywall()` which will return the new
`displayedLocale: Locale` variable
2.
[PaywallData+Validation](https://github.com/RevenueCat/purchases-ios/pull/4158/files#diff-452790cf5287a44f75a4b2260254db2281a066faeea2b3e441a007ec60522b6b)
returns `displayedLocale` if its a valid paywall, otherwise it return
`Locale.current` for fallback paywall
3.
[PaywallData+Localization](https://github.com/RevenueCat/purchases-ios/pull/4158/files#diff-e620baead94f7acad56d931c485d7691c4f909be3fe625e104528a2702cca2a0)
has a lot of the new logic
- New `locale: Locale` computer property that it determines the locale
from the `localizedConfiguration` or `localizedConfigurationsByTier`
- New ` defaultLocalizedConfiguration` that uses the developer set
`defaultLocale` (from `PaywallData`) to find the default localization
- Updated `localizedConfiguration<Value>()` to take a new
`defaultLocalization` to put in its priority (and log it if its used).
If for some reason that's not found, it will use the legacy
`fallbackLocalization` (which is the first localization it finds or
english)
4. After all of that, the `displayedLocale` is passed into the actually
SwiftUI `LoadedOfferingPaywallView` to show this locale everywhere

### Problem 1 - Inconsistent languages displayed at once

The user defined localizations and the pre-localized content in the SDK
(like links in the footer and package duration names) could should
different localizations. The choice of which locale was happening in two
different spots.

Since the SDK has all possible localizations that the dashboard offers,
the locale SDK displays from the dashboard will be chosen for the
pre-localized content.

### Problem 2 - Showing random languages if not supported

We decided early on to not use a `default_locale` but that has hurt us.
The solution to use was use `localizations.first` to show the first
locale in the list and the intention that this was the first locale the
user did in the paywall. That was not the case, however. The order of
the localizations list was indeterministic so it should should a random
language (even to the point where different random languages would show
the the same user).

We are now introducing a `default_locale` that the developer can set in
the paywall. If the locale the user is requesting is not translated by
the developer, it will use the `default_locale`

### Problem 3 - Footer not using correct localization from the bundle

There were times where some parts of the footer itself (mainly the
restore button) would be a different language than the terms of service
and privacy policy buttons. This was due to a code issue where we were
using `Bundle.module`. Instead, we needed to specify a localization
bundle specifically that we wanted to use.
2024-08-20 09:44:27 -05: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
James BorthwickandJosh Holtz 0a627d7db7 RemoteImage Low Res Image support (#3906)
`RemoteImage` now uses two images: a high res image (as before), and a
new low res image which will be used if it is available before the high
res image has been loaded.

To enable this, it now uses two `ImageLoader` instances, one for to
fetch the low res image, a second for high res image.

It assumes that the low res image url is the same as the high res image
url, with a suffix appended (default `_low_res`) to the file name, eg
`/images/myImage.jpg` is assumed to have a low res equivalent at
`/images/myImage_low_res.jpg`.

It requests the high and low res images concurrently, and displays an
error when both requests fail. A low res image will never replace a high
res image even if it is received last.

Here is a video of it loading a "low res" image, following by high res
image. For this video the low-res image was intentionally set to a
completely different image to make the change easy to see. If you move
the slider manually you can control the speed.


https://github.com/RevenueCat/purchases-ios/assets/109382862/97521803-5c12-43d0-92a8-596b23cdcff6

---------

Co-authored-by: Josh Holtz <me@joshholtz.com>
2024-05-27 12:39:18 -07:00
NachoSoto 34e00451c4 Paywalls: always dismiss paywalls automatically after a purchase (#3517)
Fixes #3516.

This logic only existed for `.presentPaywallIfNeeded`. However, it
should apply to `PaywallView` when presented manually as well.

This is also consistent with how Android works, reduces boilerplate code
required by users, and removes the possibility that a paywall is still
presented after a purchase, which doesn't make sense since our paywalls
don't have a mode for "already subscribed".
2023-12-14 09:29:44 -08:00
NachoSoto 6317f096cf Paywalls: watchOS support (#3291)
![simulator_screenshot_38A61F03-8048-40E2-AD3E-384DF4112771](https://github.com/RevenueCat/purchases-ios/assets/685609/b74e2c9d-ece1-4f00-920b-7bccb68acd82)

![simulator_screenshot_ADD7F6B5-8A39-473B-85E2-5175027F641C](https://github.com/RevenueCat/purchases-ios/assets/685609/6531d598-b859-4482-a669-34d2eaf5b8f1)
2023-11-16 12:49:53 -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
NachoSoto 9472ed7cfc Paywalls: new optional displayCloseButton parameter (#3359)
The
[documentation](https://www.revenuecat.com/docs/displaying-paywalls#close-button)
points out that this is only automatic when using
`presentPaywallIfNeeded`, but it can also be useful to have
`RevenueCatUI` do this automatically.
2023-10-30 15:46:36 -07:00
NachoSoto fc72c2a6b0 Paywalls: disable shimmering on footer loading view (#3324) 2023-10-24 07:56:45 -07:00
NachoSoto eccddc0361 Paywalls: localize default template (#3295)
![Screenshot 2023-10-09 at 14 00
55](https://github.com/RevenueCat/purchases-ios/assets/685609/4e766486-c082-452d-9abf-22d3d0080812)

_Note: the package names in this screenshot aren't localized because
they're hardcoded in these `TestStoreProduct`._
2023-10-10 16:25:26 -07:00
NachoSoto 31a411347c Paywalls: added shimmer effect to LoadingPaywallView (#3267) 2023-10-03 22:09:06 -05:00
NachoSoto aac02f571b Paywalls: simplified LoadingPaywallView (#3265)
This is a better fix than #3235. Instead of marking the view as
disabled, which is also used by `PurchaseHandler` to signal that a
purchase is in progress, we simply make that view not accept touches.

Which has the actual effect that we want (not being interactive),
without messing with how it's displayed).

![Screenshot 2023-10-02 at 14 04
49](https://github.com/RevenueCat/purchases-ios/assets/685609/049a1931-8e71-4875-b297-c00562d318e6)
2023-10-03 12:37:26 -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 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 515933c52a Paywalls: enabled Catalyst support (#3087)
![Screenshot 2023-08-24 at 15 18
51](https://github.com/RevenueCat/purchases-ios/assets/685609/7a243aad-1558-419b-b2f8-b39f620794f4)

With "native" design:
![Screenshot 2023-08-25 at 10 11
10](https://github.com/RevenueCat/purchases-ios/assets/685609/8d0c529d-0b5a-4ca5-b7b0-7462187f6864)
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 8598910c0a Paywalls: handle already purchased state (#3046) 2023-09-14 18:28:11 -07:00
NachoSoto 8c587bcc65 Paywalls: validate PaywallData to ensure displayed data is always correct (#3019)
See
https://docs.google.com/document/d/1oLZR77apAjZf04hDzlrJpPaFV0xhuQ1-hApsNBohhfA/edit

This simplifies `PaywallView`: the validated `PaywallData` is what's
always displayed, and a `DebugErrorView` containing an error description
will potentially explain why a default paywall is being displayed.

### Validations:

- [x] `Offering` contains a `PaywallData`
- [x] `PaywallLocalizedConfiguration` contains no unrecognized variables
- [x] `PaywallIcon`s are all recognized
- [x] `PaywallTemplate` is recognized
2023-09-14 18:28:11 -07:00
NachoSoto 5c9b2a62c7 Paywalls: .card and .condensedCard modes (#2995) 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 cc0152f96f Paywalls: added support for custom fonts (#2988)
This adds a new `PaywallFontProvider` `protocol` as well as 2
implementations:
- `DefaultPaywallFontProvider`: exactly equivalent to the existing font
behavior
- `CustomPaywallFontProvider` allows using a custom font name and still
use dynamic type

![image](https://github.com/RevenueCat/purchases-ios/assets/685609/3659576c-a957-4f33-86ba-e3633520aa17)

![image](https://github.com/RevenueCat/purchases-ios/assets/685609/250304ae-c2e7-4726-888c-bb454651664e)
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 6d6407fdfe Paywalls: changed default PaywallData to display available packages (#2964)
Currently it was doing a best effort and showing
`weekly`/`monthly`/`annual` if those existed in the offering. This makes
it so it doesn't have to guess, and it creates a `Paywall` using the
`availablePackages`.
2023-09-14 18:28:11 -07:00
NachoSoto 2f96345f24 Paywalls: fixed tvOS compilation by making it explicitly unavailable (#2956)
Follow up to #2821.
2023-09-14 18:28:11 -07:00
NachoSoto 955d493e9e Paywalls: fixed LoadingPaywallView displaying a progress view (#2929)
This was broken in #2923. Unfortunately it couldn't be caught by
snapshot tests because those don't display progress views.

I simplified `PackageButtonStyle` to get the shared
`PurchaseHandler.actionInProgress` state instead of relying on whether
the button is enabled.
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 b2897768f1 Paywalls: handle missing paywalls gracefully (#2855)
- Improved `PaywallView` API: now there's only 2 constructors (with
optional `Mode` parameters):
    - `PaywallView()`
    - `PaywallView(offering:)`
- New `PaywallData.default` as a fallback when trying to present a
paywall with missing data (either because it failed to decode, or it's
missing)
- Extracted error state handling to `ErrorDisplay` (used by
`PaywallView` and `AsyncButton` now). It can optionally dismiss the
presenting sheet.
- Handling offering loading errors in `PaywallView`
- Improved `DebugErrorView` to allow displaying a fallback view:
```swift
DebugErrorView(
    "Offering '\(offering.identifier)' has no configured paywall.\n" +
    "The displayed paywall contains default configuration.\n" +
    "This error will be hidden in production.",
    releaseBehavior: .replacement(
        AnyView(
            LoadedOfferingPaywallView(
                offering: offering,
                paywall: .default,
                mode: mode,
                introEligibility: checker,
                purchaseHandler: purchaseHandler
            )
        )
    )
)
```
- Added `LoadingPaywallView` as a placeholder view during loading
- Improved `MultiPackageTemplate` and `RemoteImage` to fix some layout
issues during transitions
- Added transition animations between loading state and loaded paywall
2023-09-14 18:28:11 -07:00