* 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
⚠️ 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.
## 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>
`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>
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".
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).

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
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.
`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
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`.
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.
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.
- 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