Skip to content

Commit

Permalink
Paywalls: multi-package template (#2840)
Browse files Browse the repository at this point in the history
### Changes:
- Added support for `PackageSetting.multiple`
- Changed `PackageConfiguration.multiple` to ensure at compile time that
the list of packages is not empty
- Renamed `Example1Template` to `SinglePackageTemplate`
- Extracted `RemoteImage`
- Added snapshot tests

<img width="196" alt="screenshot_2023-07-20_at_16 01 14"
src="https://github.com/RevenueCat/purchases-ios/assets/685609/c7d6ae58-a608-414a-b5b1-11638e1a2a67">
  • Loading branch information
NachoSoto committed Jul 24, 2023
1 parent 2a7122e commit 9ab691a
Show file tree
Hide file tree
Showing 48 changed files with 549 additions and 124 deletions.
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,6 @@ let package = Package(
.product(name: "SnapshotTesting", package: "swift-snapshot-testing")
],
exclude: ["__Snapshots__"],
resources: [.copy("Resources/image.png")])
resources: [.copy("Resources/image_1.jpg"), .copy("Resources/image_2.jpg")])
]
)
3 changes: 3 additions & 0 deletions RevenueCatUI/Data/ProcessedLocalizedConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,6 @@ struct ProcessedLocalizedConfiguration: PaywallLocalizedConfiguration {
}

}

@available(iOS 16.0, macOS 13.0, tvOS 16.0, *)
extension ProcessedLocalizedConfiguration: Equatable {}
21 changes: 8 additions & 13 deletions RevenueCatUI/Data/TemplateViewConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,15 @@ struct TemplateViewConfiguration {
extension TemplateViewConfiguration {

/// A `Package` with its processed localized strings.
struct Package {
struct Package: Equatable {

let content: RevenueCat.Package
let localization: ProcessedLocalizedConfiguration

}

/// Whether a template displays 1 or multiple packages.
enum PackageSetting {
enum PackageSetting: Equatable {

case single
case multiple
Expand All @@ -43,10 +43,10 @@ extension TemplateViewConfiguration {

/// Describes the possible displayed packages in a paywall.
/// See `create(with:filter:setting:)` for how to create these.
enum PackageConfiguration {
enum PackageConfiguration: Equatable {

case single(Package)
case multiple([Package])
case multiple(first: Package, all: [Package])

}

Expand All @@ -62,13 +62,8 @@ extension TemplateViewConfiguration.PackageConfiguration {
switch self {
case let .single(package):
return package
case let .multiple(packages):
guard let package = packages.first else {
// `create()` makes this impossible.
fatalError("Unexpectedly found no packages in `PackageConfiguration.multiple`")
}

return package
case let .multiple(first, _):
return first
}
}

Expand All @@ -77,7 +72,7 @@ extension TemplateViewConfiguration.PackageConfiguration {
switch self {
case let .single(package):
return [package]
case let .multiple(packages):
case let .multiple(_, packages):
return packages
}
}
Expand Down Expand Up @@ -116,7 +111,7 @@ extension TemplateViewConfiguration.PackageConfiguration {
case .single:
return .single(firstPackage)
case .multiple:
return .multiple(filtered)
return .multiple(first: firstPackage, all: filtered)
}
}

Expand Down
121 changes: 112 additions & 9 deletions RevenueCatUI/Data/TestData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,43 @@ import RevenueCat
@available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *)
internal enum TestData {

static let weeklyProduct = TestStoreProduct(
localizedTitle: "Weekly",
price: 1.99,
localizedPriceString: "$1.99",
productIdentifier: "com.revenuecat.product_1",
productType: .autoRenewableSubscription,
localizedDescription: "PRO weekly",
subscriptionGroupIdentifier: "group",
subscriptionPeriod: .init(value: 1, unit: .week)
)
static let monthlyProduct = TestStoreProduct(
localizedTitle: "Monthly",
price: 12.99,
localizedPriceString: "$12.99",
productIdentifier: "com.revenuecat.product_2",
productType: .autoRenewableSubscription,
localizedDescription: "PRO monthly",
subscriptionGroupIdentifier: "group",
subscriptionPeriod: .init(value: 1, unit: .month),
introductoryDiscount: Self.intro(7, .day)
)
static let annualProduct = TestStoreProduct(
localizedTitle: "Annual",
price: 69.49,
localizedPriceString: "$69.49",
productIdentifier: "com.revenuecat.product_3",
productType: .autoRenewableSubscription,
localizedDescription: "PRO annual",
subscriptionGroupIdentifier: "group",
subscriptionPeriod: .init(value: 1, unit: .year),
introductoryDiscount: Self.intro(14, .day)
)
static let productWithIntroOffer = TestStoreProduct(
localizedTitle: "PRO monthly",
price: 3.99,
localizedPriceString: "$3.99",
productIdentifier: "com.revenuecat.product",
productIdentifier: "com.revenuecat.product_4",
productType: .autoRenewableSubscription,
localizedDescription: "PRO monthly",
subscriptionGroupIdentifier: "group",
Expand All @@ -37,14 +69,33 @@ internal enum TestData {
localizedTitle: "PRO annual",
price: 34.99,
localizedPriceString: "$34.99",
productIdentifier: "com.revenuecat.product",
productIdentifier: "com.revenuecat.product_3",
productType: .autoRenewableSubscription,
localizedDescription: "PRO annual",
subscriptionGroupIdentifier: "group",
subscriptionPeriod: .init(value: 1, unit: .year),
introductoryDiscount: nil,
discounts: []
)
static let weeklyPackage = Package(
identifier: "weekly",
packageType: .weekly,
storeProduct: Self.weeklyProduct.toStoreProduct(),
offeringIdentifier: Self.offeringIdentifier
)
static let monthlyPackage = Package(
identifier: "monthly",
packageType: .monthly,
storeProduct: Self.monthlyProduct.toStoreProduct(),
offeringIdentifier: Self.offeringIdentifier
)
static let annualPackage = Package(
identifier: "annual",
packageType: .annual,
storeProduct: Self.annualProduct.toStoreProduct(),
offeringIdentifier: Self.offeringIdentifier
)

static let packageWithIntroOffer = Package(
identifier: "monthly",
packageType: .monthly,
Expand All @@ -64,23 +115,23 @@ internal enum TestData {
]

static let paywallWithIntroOffer = PaywallData(
template: .example1,
template: .singlePackage,
config: .init(
packages: [.monthly],
imageNames: [Self.paywallHeaderImageName],
colors: .init(light: Self.lightColors, dark: Self.darkColors)
),
localization: Self.localization,
localization: Self.localization1,
assetBaseURL: Self.paywallAssetBaseURL
)
static let paywallWithNoIntroOffer = PaywallData(
template: .example1,
template: .singlePackage,
config: .init(
packages: [.annual],
imageNames: [Self.paywallHeaderImageName],
colors: .init(light: Self.lightColors, dark: Self.darkColors)
),
localization: Self.localization,
localization: Self.localization1,
assetBaseURL: Self.paywallAssetBaseURL
)

Expand All @@ -100,6 +151,39 @@ internal enum TestData {
availablePackages: Self.packages
)

static let offeringWithMultiPackagePaywall = Offering(
identifier: Self.offeringIdentifier,
serverDescription: "Offering",
metadata: [:],
paywall: .init(
template: .multiPackage,
config: .init(
packages: [.annual, .monthly],
imageNames: [Self.paywallBackgroundImageName,
Self.paywallHeaderImageName],
colors: .init(
light: .init(
background: "#FFFFFF",
foreground: "#000000",
callToActionBackground: "#EC807C",
callToActionForeground: "#FFFFFF"
),
dark: .init(
background: "#000000",
foreground: "#FFFFFF",
callToActionBackground: "#ACD27A",
callToActionForeground: "#000000"
)
)
),
localization: Self.localization2,
assetBaseURL: Self.paywallAssetBaseURL
),
availablePackages: [Self.weeklyPackage,
Self.monthlyPackage,
Self.annualPackage]
)

static let lightColors: PaywallData.Configuration.Colors = .init(
background: "#FFFFFF",
foreground: "#000000",
Expand Down Expand Up @@ -150,19 +234,38 @@ internal enum TestData {
return try! decoder.decode(CustomerInfo.self, from: Data(json.utf8))
}()

static let localization: PaywallData.LocalizedConfiguration = .init(
static let localization1: PaywallData.LocalizedConfiguration = .init(
title: "Ignite your child's curiosity",
subtitle: "Get access to all our educational content trusted by thousands of parents.",
callToAction: "Purchase for {{ price }}",
callToActionWithIntroOffer: "Purchase for {{ price_per_month }} per month",
offerDetails: "{{ price_per_month }} per month",
offerDetailsWithIntroOffer: "Start your {{ intro_duration }} trial, then {{ price_per_month }} per month"
)
static let localization2: PaywallData.LocalizedConfiguration = .init(
title: "Call to action for better conversion.",
subtitle: "Lorem ipsum is simply dummy text of the printing and typesetting industry.",
callToAction: "Subscribe for {{ price_per_month }}/mo",
offerDetails: "{{ total_price_and_per_month }}",
offerDetailsWithIntroOffer: "{{ total_price_and_per_month }} after {{ intro_duration }} trial"
)
static let paywallHeaderImageName = "9a17e0a7_1689854430..jpeg"
static let paywallBackgroundImageName = "9a17e0a7_1689854342..jpg"
static let paywallAssetBaseURL = URL(string: "https://d35rwhxn1vk1te.cloudfront.net")!

private static let offeringIdentifier = "offering"
private static let paywallHeaderImageName = "cd84ac55_paywl0884b9ceb4_header_1689214657.jpg"
private static let paywallAssetBaseURL = URL(string: "https://d2ban7feka8lu3.cloudfront.net")!

private static func intro(_ duration: Int, _ unit: SubscriptionPeriod.Unit) -> TestStoreProductDiscount {
return .init(
identifier: "intro",
price: 0,
localizedPriceString: "$0.00",
paymentMode: .freeTrial,
subscriptionPeriod: .init(value: duration, unit: .day),
numberOfPeriods: 1,
type: .introductory
)
}
}

@available(iOS 16.0, macOS 13.0, tvOS 16.0, *)
Expand Down
31 changes: 22 additions & 9 deletions RevenueCatUI/PaywallView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -170,29 +170,42 @@ private extension PaywallViewMode {
struct PaywallView_Previews: PreviewProvider {

static var previews: some View {
let offering = TestData.offeringWithNoIntroOffer

if let paywall = offering.paywall {
ForEach(PaywallViewMode.allCases, id: \.self) { mode in
ForEach(Self.offerings, id: \.self) { offering in
ForEach(Self.modes, id: \.self) { mode in
PaywallView(
offering: offering,
paywall: paywall,
paywall: offering.paywall!,
mode: mode,
introEligibility: Self.introEligibility,
purchaseHandler: Self.purchaseHandler
)
.previewLayout(mode.layout)
.previewDisplayName("\(offering.identifier)-\(mode)")
}
} else {
Text("Preview not correctly setup, offering has no paywall!")
}
}

private static let introEligibility: TrialOrIntroEligibilityChecker = .producing(eligibility: .eligible)
private static let introEligibility: TrialOrIntroEligibilityChecker =
.producing(eligibility: .eligible)
.with(delay: .seconds(1))
private static let purchaseHandler: PurchaseHandler = .mock()
private static let purchaseHandler: PurchaseHandler =
.mock()
.with(delay: .seconds(1))

private static let offerings: [Offering] = [
TestData.offeringWithIntroOffer,
TestData.offeringWithMultiPackagePaywall
]

private static let modes: [PaywallViewMode] = [
.fullScreen
]

private static let colors: PaywallData.Configuration.ColorInformation = .init(
light: TestData.lightColors,
dark: TestData.darkColors
)

}

@available(iOS 16.0, macOS 13.0, tvOS 16.0, *)
Expand Down
Loading

0 comments on commit 9ab691a

Please sign in to comment.