Skip to content

Commit

Permalink
Paywalls: added packages to configuration (#2798)
Browse files Browse the repository at this point in the history
This allows configuring which package(s) will be displayed in each
paywall.
  • Loading branch information
NachoSoto committed Aug 31, 2023
1 parent c918543 commit 37f8390
Show file tree
Hide file tree
Showing 15 changed files with 199 additions and 62 deletions.
18 changes: 18 additions & 0 deletions RevenueCatUI/Helpers/Logger.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
//
// Logger.swift
//
//
// Created by Nacho Soto on 7/12/23.
//

import RevenueCat

enum Logger {

static func warning(_ text: String) {
// Note: this isn't ideal.
// Once we can use the `package` keyword it can use the internal `Logger`.
Purchases.logHandler(.warn, text)
}

}
10 changes: 2 additions & 8 deletions RevenueCatUI/Helpers/Variables.swift
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ private extension VariableDataProvider {
case "product_name": return self.productName
case "intro_duration":
guard let introDuration = self.introductoryOfferDuration else {
Self.logWarning(
Logger.warning(
"Unexpectedly tried to look for intro duration when there is none, this is a logic error."
)
return ""
Expand All @@ -83,15 +83,9 @@ private extension VariableDataProvider {
return introDuration

default:
Self.logWarning("Couldn't find content for variable '\(variableName)'")
Logger.warning("Couldn't find content for variable '\(variableName)'")
return ""
}
}

private static func logWarning(_ text: String) {
// Note: this isn't ideal.
// Once we can use the `package` keyword it can use the internal `Logger`.
Purchases.logHandler(.warn, text)
}

}
3 changes: 2 additions & 1 deletion RevenueCatUI/PaywallView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ public struct PaywallView: View {
struct PaywallView_Previews: PreviewProvider {

static var previews: some View {
PaywallView(offering: TestData.offering, paywall: TestData.paywall)
let offering = TestData.offeringWithIntroOffer
PaywallView(offering: offering, paywall: offering.paywall!)
}

}
Expand Down
103 changes: 84 additions & 19 deletions RevenueCatUI/Templates/Example1Template.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,62 @@ import SwiftUI
@available(iOS 16.0, macOS 13.0, tvOS 16.0, *)
struct Example1Template: TemplateViewType {

private let package: Package
private let localization: ProcessedLocalizedConfiguration
private let configuration: PaywallData.Configuration
private var data: Result<Example1TemplateContent.Data, Example1TemplateContent.Error>

init(
packages: [Package],
localization: PaywallData.LocalizedConfiguration,
configuration: PaywallData.Configuration
) {
// The RC SDK ensures this when constructing Offerings
precondition(!packages.isEmpty)
// Fix-me: move this logic out to be used by all templates
if packages.isEmpty {
self.data = .failure(.noPackages)
} else {
let packages = Self.filter(packages: packages, with: configuration.packages)

if let package = packages.first {
self.data = .success(.init(
package: package,
localization: localization.processVariables(with: package),
configuration: configuration
))
} else {
self.data = .failure(.couldNotFindAnyPackages(expectedTypes: configuration.packages))
}
}
}

self.package = packages[0]
self.localization = localization.processVariables(with: self.package)
self.configuration = configuration
// Fix-me: this can be extracted to be used by all templates
var body: some View {
switch self.data {
case let .success(data):
Example1TemplateContent(data: data)
case let .failure(error):
#if DEBUG
// Fix-me: implement a proper production error screen
EmptyView()
.onAppear {
Logger.warning("Couldn't load paywall: \(error.description)")
}
#else
Text(error.description)
.background(
Color.red
.edgesIgnoringSafeArea(.all)
)
#endif
}
}

precondition(
self.package.storeProduct.productCategory == .subscription,
"Unexpected product type for this template: \(self.package.storeProduct.productType)"
)
}

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

private var data: Data

init(data: Data) {
self.data = data
}

var body: some View {
Expand All @@ -50,12 +86,12 @@ struct Example1Template: TemplateViewType {
Spacer()

VStack {
Text(verbatim: self.localization.title)
Text(verbatim: self.data.localization.title)
.font(.largeTitle)
.fontWeight(.heavy)
.padding(.bottom)

Text(verbatim: self.localization.subtitle)
Text(verbatim: self.data.localization.subtitle)
.font(.subheadline)
.padding(.horizontal)

Expand All @@ -73,9 +109,9 @@ struct Example1Template: TemplateViewType {
@ViewBuilder
private var offerDetails: some View {
// Fix-me: this needs to handle other types of intro discounts
let text = self.package.storeProduct.introductoryDiscount == nil
? self.localization.offerDetails
: self.localization.offerDetailsWithIntroOffer
let text = self.data.package.storeProduct.introductoryDiscount == nil
? self.data.localization.offerDetails
: self.data.localization.offerDetailsWithIntroOffer

Text(verbatim: text)
.font(.callout)
Expand All @@ -86,7 +122,7 @@ struct Example1Template: TemplateViewType {
Button {

} label: {
Text(self.localization.callToAction)
Text(self.data.localization.callToAction)
.frame(maxWidth: .infinity)
}
.font(.title2)
Expand All @@ -99,8 +135,23 @@ struct Example1Template: TemplateViewType {

}

// MARK: -

@available(iOS 16.0, macOS 13.0, tvOS 16.0, *)
private extension Example1Template {
private extension Example1TemplateContent {

struct Data {
let package: Package
let localization: ProcessedLocalizedConfiguration
let configuration: PaywallData.Configuration
}

enum Error: Swift.Error {

case noPackages
case couldNotFindAnyPackages(expectedTypes: [PackageType])

}

private func label(for package: Package) -> some View {
HStack {
Expand All @@ -120,3 +171,17 @@ private extension Example1Template {
}

}

@available(iOS 16.0, macOS 13.0, tvOS 16.0, *)
extension Example1TemplateContent.Error {

var description: String {
switch self {
case .noPackages:
return "Attempted to display paywall with no packages."
case let .couldNotFindAnyPackages(expectedTypes):
return "Couldn't find any requested packages: \(expectedTypes)"
}
}

}
29 changes: 29 additions & 0 deletions RevenueCatUI/Templates/TemplateViewType.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,32 @@ extension PaywallData {
}
}
}

@available(iOS 16.0, macOS 13.0, tvOS 16.0, *)
extension TemplateViewType {

static func filter(packages: [Package], with list: [PackageType]) -> [Package] {
// Only subscriptions are supported at the moment
let subscriptions = packages.filter { $0.storeProduct.productCategory == .subscription }
let map = Dictionary(grouping: subscriptions) { $0.packageType }

return list.compactMap { type in
if let packages = map[type] {
switch packages.count {
case 0:
// This isn't actually possible because of `Dictionary(grouping:by:)
return nil
case 1:
return packages.first
default:
Logger.warning("Found multiple \(type) packages. Will use the first one.")
return packages.first
}
} else {
Logger.warning("Couldn't find '\(type)'")
return nil
}
}
}

}
56 changes: 32 additions & 24 deletions RevenueCatUI/TestData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,41 +57,49 @@ internal enum TestData {
offeringIdentifier: Self.offeringIdentifier
)

static let paywall = PaywallData(
static let packages = [
Self.packageWithIntroOffer,
Self.packageWithNoIntroOffer
]

static let paywallWithIntroOffer = PaywallData(
template: .example1,
config: .init(),
localization: .init(
title: "Ignite your child's curiosity",
subtitle: "Get access to all our educational content trusted by thousands of parents.",
callToAction: "Continue",
callToActionWithIntroOffer: "Continue",
offerDetails: "{{ price_per_month }} per month",
offerDetailsWithIntroOffer: "Start your {{ intro_duration }} trial, then {{ price_per_month }} per month"
)
config: .init(
packages: [.monthly]
),
localization: Self.localization
)
static let paywallWithNoIntroOffer = PaywallData(
template: .example1,
config: .init(
packages: [.annual]
),
localization: Self.localization
)

// Fix-me: remove this when we can filter by package type

static let offering = Offering(
static let offeringWithIntroOffer = Offering(
identifier: Self.offeringIdentifier,
serverDescription: "Main offering",
metadata: [:],
paywall: Self.paywall,
availablePackages: [
packageWithNoIntroOffer,
packageWithIntroOffer
]
paywall: Self.paywallWithIntroOffer,
availablePackages: Self.packages
)

static let offeringWithIntroOffer = Offering(
static let offeringWithNoIntroOffer = Offering(
identifier: Self.offeringIdentifier,
serverDescription: "Main offering",
metadata: [:],
paywall: Self.paywall,
availablePackages: [
packageWithIntroOffer,
packageWithNoIntroOffer
]
paywall: Self.paywallWithNoIntroOffer,
availablePackages: Self.packages
)

private static let localization: PaywallData.LocalizedConfiguration = .init(
title: "Ignite your child's curiosity",
subtitle: "Get access to all our educational content trusted by thousands of parents.",
callToAction: "Continue",
callToActionWithIntroOffer: "Continue",
offerDetails: "{{ price_per_month }} per month",
offerDetailsWithIntroOffer: "Start your {{ intro_duration }} trial, then {{ price_per_month }} per month"
)

private static let offeringIdentifier = "offering"
Expand Down
7 changes: 6 additions & 1 deletion Sources/Paywalls/PaywallData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -118,8 +118,13 @@ extension PaywallData {
/// Generic configuration for any paywall.
public struct Configuration {

/// The list of package types this paywall will display
public var packages: [PackageType]

// swiftlint:disable:next missing_docs
public init() {}
public init(packages: [PackageType]) {
self.packages = packages
}

}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ func checkPaywallData(_ data: PaywallData) {
}

func checkPaywallConfiguration(_ config: PaywallData.Configuration) {
let _: PaywallData.Configuration = .init()
let _: PaywallData.Configuration = .init(packages: [.monthly, .annual])
let _: [PackageType] = config.packages
}

func checkPaywallLocalizedConfig(_ config: PaywallData.LocalizedConfiguration) {
Expand Down
14 changes: 10 additions & 4 deletions Tests/RevenueCatUITests/PaywallViewTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,23 @@ class PaywallViewTests: TestCase {
}

func testSamplePaywall() {
let view = PaywallView(offering: TestData.offering, paywall: TestData.paywall)
.frame(width: 460, height: 950)
let offering = TestData.offeringWithNoIntroOffer

let view = PaywallView(offering: offering, paywall: offering.paywall!)
.frame(width: Self.size.width, height: Self.size.height)

expect(view).to(haveValidSnapshot(as: .image))
}

func testSamplePaywallWithIntroOffer() {
let view = PaywallView(offering: TestData.offeringWithIntroOffer, paywall: TestData.paywall)
.frame(width: 460, height: 950)
let offering = TestData.offeringWithIntroOffer

let view = PaywallView(offering: offering, paywall: offering.paywall!)
.frame(width: Self.size.width, height: Self.size.height)

expect(view).to(haveValidSnapshot(as: .image))
}

private static let size: CGSize = .init(width: 460, height: 950)

}
4 changes: 3 additions & 1 deletion Tests/UnitTests/Networking/Responses/Fixtures/Offerings.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,9 @@
}
},
"default_locale": "en_US",
"config": {}
"config": {
"packages": ["$rc_monthly", "$rc_annual"]
}
}
},
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,7 @@
}
},
"default_locale": "en_US",
"config": {}
"config": {
"packages": ["$rc_monthly", "$rc_annual"]
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,7 @@
}
},
"default_locale": "es_ES",
"config": {}
"config": {
"packages": ["$rc_monthly", "$rc_annual"]
}
}
Loading

0 comments on commit 37f8390

Please sign in to comment.