Skip to content

Commit

Permalink
Paywalls: handle missing paywalls gracefully (#2855)
Browse files Browse the repository at this point in the history
- 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
  • Loading branch information
NachoSoto committed Aug 14, 2023
1 parent 2c3e464 commit ac8b868
Show file tree
Hide file tree
Showing 25 changed files with 533 additions and 195 deletions.
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ let package = Package(
.target(name: "RevenueCatUI",
dependencies: ["RevenueCat"],
path: "RevenueCatUI",
resources: []),
resources: [.copy("Resources/background.jpg")]),
.testTarget(name: "RevenueCatUITests",
dependencies: [
"RevenueCatUI",
Expand Down
33 changes: 33 additions & 0 deletions RevenueCatUI/Data/Errors/PaywallError.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
//
// PaywalError.swift
//
//
// Created by Nacho Soto on 7/21/23.
//

import Foundation

/// Error produced when displaying paywalls.
enum PaywallError: Error {

/// RevenueCat dashboard does not have a current offering configured
case noCurrentOffering

}

extension PaywallError: CustomNSError {

var errorUserInfo: [String: Any] {
return [
NSLocalizedDescriptionKey: self.description
]
}

private var description: String {
switch self {
case .noCurrentOffering:
return "The RevenueCat dashboard does not have a current offering configured."
}
}

}
File renamed without changes.
35 changes: 35 additions & 0 deletions RevenueCatUI/Data/LocalizedAlertError.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
//
// File.swift
//
//
// Created by Nacho Soto on 7/21/23.
//

import RevenueCat
import SwiftUI

struct LocalizedAlertError: LocalizedError {

private let underlyingError: NSError

init(error: NSError) {
self.underlyingError = error
}

var errorDescription: String? {
return "\(self.underlyingError.domain) \(self.underlyingError.code)"
}

var failureReason: String? {
if let errorCode = self.underlyingError as? ErrorCode {
return errorCode.description
} else {
return self.underlyingError.localizedDescription
}
}

var recoverySuggestion: String? {
self.underlyingError.localizedRecoverySuggestion
}

}
4 changes: 2 additions & 2 deletions RevenueCatUI/Data/TestData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ internal enum TestData {
template: .singlePackage,
config: .init(
packages: [.monthly],
imageNames: [Self.paywallHeaderImageName],
imageNames: [Self.paywallBackgroundImageName, Self.paywallHeaderImageName],
colors: .init(light: Self.lightColors, dark: Self.darkColors),
termsOfServiceURL: URL(string: "https://revenuecat.com/tos")!,
privacyURL: URL(string: "https://revenuecat.com/privacy")!
Expand All @@ -130,7 +130,7 @@ internal enum TestData {
template: .singlePackage,
config: .init(
packages: [.annual],
imageNames: [Self.paywallHeaderImageName],
imageNames: [Self.paywallBackgroundImageName, Self.paywallHeaderImageName],
colors: .init(light: Self.lightColors, dark: Self.darkColors)
),
localization: Self.localization1,
Expand Down
66 changes: 66 additions & 0 deletions RevenueCatUI/Helpers/PaywallData+Default.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
//
// PaywallData+Default.swift
//
//
// Created by Nacho Soto on 7/20/23.
//

import Foundation
import RevenueCat

#if canImport(SwiftUI) && swift(>=5.7)

extension PaywallData {

/// Default `PaywallData` to display when attempting to present a ``PaywallView`` with an offering
/// that has no paywall configuration, or when that configuration is invalid.
public static let `default`: Self = .init(
template: .multiPackage,
config: .init(
packages: [.weekly, .monthly, .annual],
imageNames: [
Self.backgroundImage
],
colors: Self.colors,
blurredBackgroundImage: true,
displayRestorePurchases: true
),
localization: Self.localization,
assetBaseURL: Self.defaultTemplateBaseURL
)

}

private extension PaywallData {

// swiftlint:disable force_try
static let colors: PaywallData.Configuration.ColorInformation = .init(
light: .init(
background: try! .init(stringRepresentation: "#FFFFFF"),
foreground: try! .init(stringRepresentation: "#000000"),
callToActionBackground: try! .init(stringRepresentation: "#EC807C"),
callToActionForeground: try! .init(stringRepresentation: "#FFFFFF")
),
dark: .init(
background: try! .init(stringRepresentation: "#000000"),
foreground: try! .init(stringRepresentation: "#FFFFFF"),
callToActionBackground: try! .init(stringRepresentation: "#ACD27A"),
callToActionForeground: try! .init(stringRepresentation: "#000000")
)
)
// swiftlint:enable force_try

static let localization: PaywallData.LocalizedConfiguration = .init(
title: "Subscription",
subtitle: "Unlock access",
callToAction: "Purchase",
offerDetails: "{{ price_per_month }} per month",
offerDetailsWithIntroOffer: "Start your {{ intro_duration }} trial, then {{ price_per_month }} per month"
)

static let backgroundImage = "background.jpg"
static let defaultTemplateBaseURL = Bundle.module.resourceURL ?? Bundle.module.bundleURL

}

#endif
92 changes: 63 additions & 29 deletions RevenueCatUI/PaywallView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,28 +15,29 @@ public struct PaywallView: View {
@State
private var offering: Offering?
@State
private var paywall: PaywallData?
private var error: NSError?

/// Create a view that loads the `Offerings.current`.
/// - Note: If loading the current `Offering` fails (if the user is offline, for example),
/// an error will be displayed.
/// - Warning: `Purchases` must have been configured prior to displaying it.
/// If you want to handle that, you can use ``init(offering:mode:)`` instead.
public init(mode: PaywallViewMode = .default) {
self.init(
offering: nil,
paywall: nil,
mode: mode,
introEligibility: Purchases.isConfigured ? .init() : nil,
purchaseHandler: Purchases.isConfigured ? .init() : nil
)
}

/// Create a view for the given offering and paywal.
/// Create a view for the given `Offering`.
/// - Note: if `offering` does not have a current paywall, or it fails to load due to invalid data,
/// a default paywall will be displayed.
/// - Warning: `Purchases` must have been configured prior to displaying it.
public init(offering: Offering,
paywall: PaywallData,
mode: PaywallViewMode = .default) {
public init(offering: Offering, mode: PaywallViewMode = .default) {
self.init(
offering: offering,
paywall: paywall,
mode: mode,
introEligibility: Purchases.isConfigured ? .init() : nil,
purchaseHandler: Purchases.isConfigured ? .init() : nil
Expand All @@ -45,40 +46,43 @@ public struct PaywallView: View {

init(
offering: Offering?,
paywall: PaywallData?,
mode: PaywallViewMode = .default,
introEligibility: TrialOrIntroEligibilityChecker?,
purchaseHandler: PurchaseHandler?
) {
self._offering = .init(initialValue: offering)
self._paywall = .init(initialValue: paywall)
self.introEligibility = introEligibility
self.purchaseHandler = purchaseHandler
self.mode = mode
}

// swiftlint:disable:next missing_docs
public var body: some View {
self.content
.displayError(self.$error, dismissOnClose: true)
}

@ViewBuilder
private var content: some View {
if let checker = self.introEligibility, let purchaseHandler = self.purchaseHandler {
if let offering = self.offering {
if let paywall = self.paywall {
LoadedOfferingPaywallView(
offering: offering,
paywall: paywall,
mode: mode,
introEligibility: checker,
purchaseHandler: purchaseHandler
)
} else {
DebugErrorView("Offering '\(offering.identifier)' has no configured paywall",
releaseBehavior: .emptyView)
}
self.paywallView(for: offering,
checker: checker,
purchaseHandler: purchaseHandler)
.transition(Self.transition)
} else {
self.loadingView
LoadingPaywallView()
.transition(Self.transition)
.task {
// Fix-me: better error handling
self.offering = try? await Purchases.shared.offerings().current
self.paywall = self.offering?.paywall
do {
guard let offering = try await Purchases.shared.offerings().current else {
throw PaywallError.noCurrentOffering
}

self.offering = offering
} catch let error as NSError {
self.error = error
}
}
}
} else {
Expand All @@ -87,14 +91,45 @@ public struct PaywallView: View {
}

@ViewBuilder
private var loadingView: some View {
ProgressView()
private func paywallView(
for offering: Offering,
checker: TrialOrIntroEligibilityChecker,
purchaseHandler: PurchaseHandler
) -> some View {
if let paywall = offering.paywall {
LoadedOfferingPaywallView(
offering: offering,
paywall: paywall,
mode: mode,
introEligibility: checker,
purchaseHandler: purchaseHandler
)
} else {
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
)
)
)
)
}
}

private static let transition: AnyTransition = .opacity.animation(Constants.defaultAnimation)

}

@available(iOS 16.0, macOS 13.0, tvOS 16.0, *)
private struct LoadedOfferingPaywallView: View {
struct LoadedOfferingPaywallView: View {

private let offering: Offering
private let paywall: PaywallData
Expand Down Expand Up @@ -177,7 +212,6 @@ struct PaywallView_Previews: PreviewProvider {
ForEach(Self.modes, id: \.self) { mode in
PaywallView(
offering: offering,
paywall: offering.paywall!,
mode: mode,
introEligibility: Self.introEligibility,
purchaseHandler: Self.purchaseHandler
Expand Down
Binary file added RevenueCatUI/Resources/background.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
21 changes: 13 additions & 8 deletions RevenueCatUI/Templates/MultiPackageTemplate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ private struct MultiPackageTemplateContent: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background {
self.backgroundImage
.unredacted()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.edgesIgnoringSafeArea(.all)
}
Expand All @@ -69,7 +70,6 @@ private struct MultiPackageTemplateContent: View {
var content: some View {
VStack(spacing: 10) {
self.iconImage
.padding(.top)

ViewThatFits(in: .vertical) {
self.scrollableContent
Expand Down Expand Up @@ -198,16 +198,21 @@ private struct MultiPackageTemplateContent: View {

@ViewBuilder
private var iconImage: some View {
if let url = self.configuration.iconURL {
RemoteImage(url: url, aspectRatio: 1)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.frame(maxWidth: 100)
} else {
DebugErrorView("Template configuration is missing icon URL",
releaseBehavior: .emptyView)
Group {
if let url = self.configuration.iconURL {
RemoteImage(url: url, aspectRatio: 1, maxWidth: Self.iconSize)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
} else {
// Placeholder to be able to add a consistent padding
Text(verbatim: "")
.hidden()
}
}
.padding(.top)
}

private static let iconSize: CGFloat = 100

}

// MARK: - Extensions
Expand Down
10 changes: 7 additions & 3 deletions RevenueCatUI/Templates/TemplateViewType.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,13 @@ extension PaywallData {
case let .success(configuration):
Self.createView(template: self.template, configuration: configuration)
.background(
mode.shouldDisplayBackground
? configuration.colors.backgroundColor
: nil
Rectangle()
.foregroundColor(
mode.shouldDisplayBackground
? configuration.colors.backgroundColor
: .clear
)
.edgesIgnoringSafeArea(.all)
)

case let .failure(error):
Expand Down
Loading

0 comments on commit ac8b868

Please sign in to comment.