From aafff1591e5cdfac2c86accbb2a35f491c9c2d4c Mon Sep 17 00:00:00 2001 From: NachoSoto Date: Wed, 12 Jul 2023 15:14:30 -0700 Subject: [PATCH] `Paywalls`: added `packages` to configuration This allows configuring which package(s) will be displayed in each paywall. --- RevenueCatUI/Helpers/Logger.swift | 18 +++ RevenueCatUI/Helpers/Variables.swift | 10 +- RevenueCatUI/PaywallView.swift | 3 +- RevenueCatUI/Templates/Example1Template.swift | 103 ++++++++++++++---- RevenueCatUI/Templates/TemplateViewType.swift | 29 +++++ RevenueCatUI/TestData.swift | 56 ++++++---- Sources/Paywalls/PaywallData.swift | 7 +- .../SwiftAPITester/PaywallAPI.swift | 3 +- .../RevenueCatUITests/PaywallViewTests.swift | 14 ++- .../Responses/Fixtures/Offerings.json | 4 +- .../Fixtures/PaywallData-Sample1.json | 4 +- ...ta-missing_current_and_default_locale.json | 4 +- .../PaywallData-missing_current_locale.json | 4 +- .../Responses/OfferingsDecodingTests.swift | 1 + .../Responses/PaywallDataTests.swift | 1 + 15 files changed, 199 insertions(+), 62 deletions(-) create mode 100644 RevenueCatUI/Helpers/Logger.swift diff --git a/RevenueCatUI/Helpers/Logger.swift b/RevenueCatUI/Helpers/Logger.swift new file mode 100644 index 0000000000..715f710612 --- /dev/null +++ b/RevenueCatUI/Helpers/Logger.swift @@ -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) + } + +} diff --git a/RevenueCatUI/Helpers/Variables.swift b/RevenueCatUI/Helpers/Variables.swift index dc233db217..a37b878218 100644 --- a/RevenueCatUI/Helpers/Variables.swift +++ b/RevenueCatUI/Helpers/Variables.swift @@ -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 "" @@ -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) - } - } diff --git a/RevenueCatUI/PaywallView.swift b/RevenueCatUI/PaywallView.swift index a0cf536ec8..8fc3b9187b 100644 --- a/RevenueCatUI/PaywallView.swift +++ b/RevenueCatUI/PaywallView.swift @@ -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!) } } diff --git a/RevenueCatUI/Templates/Example1Template.swift b/RevenueCatUI/Templates/Example1Template.swift index 1e65cfa459..64d4caa9dc 100644 --- a/RevenueCatUI/Templates/Example1Template.swift +++ b/RevenueCatUI/Templates/Example1Template.swift @@ -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 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 { @@ -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) @@ -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) @@ -86,7 +122,7 @@ struct Example1Template: TemplateViewType { Button { } label: { - Text(self.localization.callToAction) + Text(self.data.localization.callToAction) .frame(maxWidth: .infinity) } .font(.title2) @@ -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 { @@ -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)" + } + } + +} diff --git a/RevenueCatUI/Templates/TemplateViewType.swift b/RevenueCatUI/Templates/TemplateViewType.swift index 04391a0865..819c3e2429 100644 --- a/RevenueCatUI/Templates/TemplateViewType.swift +++ b/RevenueCatUI/Templates/TemplateViewType.swift @@ -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 + } + } + } + +} diff --git a/RevenueCatUI/TestData.swift b/RevenueCatUI/TestData.swift index 4f5ccd7e9c..deb3e282f8 100644 --- a/RevenueCatUI/TestData.swift +++ b/RevenueCatUI/TestData.swift @@ -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" diff --git a/Sources/Paywalls/PaywallData.swift b/Sources/Paywalls/PaywallData.swift index b1394adb0d..04f718b331 100644 --- a/Sources/Paywalls/PaywallData.swift +++ b/Sources/Paywalls/PaywallData.swift @@ -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 + } } diff --git a/Tests/APITesters/SwiftAPITester/SwiftAPITester/PaywallAPI.swift b/Tests/APITesters/SwiftAPITester/SwiftAPITester/PaywallAPI.swift index 302480ad27..7ddd7542ae 100644 --- a/Tests/APITesters/SwiftAPITester/SwiftAPITester/PaywallAPI.swift +++ b/Tests/APITesters/SwiftAPITester/SwiftAPITester/PaywallAPI.swift @@ -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) { diff --git a/Tests/RevenueCatUITests/PaywallViewTests.swift b/Tests/RevenueCatUITests/PaywallViewTests.swift index 6541cee6de..bc7007c664 100644 --- a/Tests/RevenueCatUITests/PaywallViewTests.swift +++ b/Tests/RevenueCatUITests/PaywallViewTests.swift @@ -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) + } diff --git a/Tests/UnitTests/Networking/Responses/Fixtures/Offerings.json b/Tests/UnitTests/Networking/Responses/Fixtures/Offerings.json index 2218554d26..3e2fb9e9e0 100644 --- a/Tests/UnitTests/Networking/Responses/Fixtures/Offerings.json +++ b/Tests/UnitTests/Networking/Responses/Fixtures/Offerings.json @@ -59,7 +59,9 @@ } }, "default_locale": "en_US", - "config": {} + "config": { + "packages": ["$rc_monthly", "$rc_annual"] + } } }, { diff --git a/Tests/UnitTests/Networking/Responses/Fixtures/PaywallData-Sample1.json b/Tests/UnitTests/Networking/Responses/Fixtures/PaywallData-Sample1.json index bfcdc386dc..6f7a703660 100644 --- a/Tests/UnitTests/Networking/Responses/Fixtures/PaywallData-Sample1.json +++ b/Tests/UnitTests/Networking/Responses/Fixtures/PaywallData-Sample1.json @@ -19,5 +19,7 @@ } }, "default_locale": "en_US", - "config": {} + "config": { + "packages": ["$rc_monthly", "$rc_annual"] + } } diff --git a/Tests/UnitTests/Networking/Responses/Fixtures/PaywallData-missing_current_and_default_locale.json b/Tests/UnitTests/Networking/Responses/Fixtures/PaywallData-missing_current_and_default_locale.json index 171e29706e..9c99fd6e1d 100644 --- a/Tests/UnitTests/Networking/Responses/Fixtures/PaywallData-missing_current_and_default_locale.json +++ b/Tests/UnitTests/Networking/Responses/Fixtures/PaywallData-missing_current_and_default_locale.json @@ -11,5 +11,7 @@ } }, "default_locale": "es_ES", - "config": {} + "config": { + "packages": ["$rc_monthly", "$rc_annual"] + } } diff --git a/Tests/UnitTests/Networking/Responses/Fixtures/PaywallData-missing_current_locale.json b/Tests/UnitTests/Networking/Responses/Fixtures/PaywallData-missing_current_locale.json index 7875b82193..a158c67cd6 100644 --- a/Tests/UnitTests/Networking/Responses/Fixtures/PaywallData-missing_current_locale.json +++ b/Tests/UnitTests/Networking/Responses/Fixtures/PaywallData-missing_current_locale.json @@ -11,5 +11,7 @@ } }, "default_locale": "es_ES", - "config": {} + "config": { + "packages": ["$rc_monthly", "$rc_annual"] + } } diff --git a/Tests/UnitTests/Networking/Responses/OfferingsDecodingTests.swift b/Tests/UnitTests/Networking/Responses/OfferingsDecodingTests.swift index b29f7a5567..70a315be02 100644 --- a/Tests/UnitTests/Networking/Responses/OfferingsDecodingTests.swift +++ b/Tests/UnitTests/Networking/Responses/OfferingsDecodingTests.swift @@ -110,6 +110,7 @@ class OfferingsDecodingTests: BaseHTTPResponseTest { let paywall = try XCTUnwrap(offering.paywall) expect(paywall.template) == .example1 expect(paywall.defaultLocale) == Locale(identifier: "en_US") + expect(paywall.config.packages) == [.monthly, .annual] let enConfig = try XCTUnwrap(paywall.config(for: Locale(identifier: "en_US"))) expect(enConfig.title) == "Paywall" diff --git a/Tests/UnitTests/Networking/Responses/PaywallDataTests.swift b/Tests/UnitTests/Networking/Responses/PaywallDataTests.swift index 4649c4952b..d6ccd8f913 100644 --- a/Tests/UnitTests/Networking/Responses/PaywallDataTests.swift +++ b/Tests/UnitTests/Networking/Responses/PaywallDataTests.swift @@ -31,6 +31,7 @@ class PaywallDataTests: BaseHTTPResponseTest { expect(paywall.template) == .example1 expect(paywall.defaultLocale) == Locale(identifier: Self.defaultLocale) + expect(paywall.config.packages) == [.monthly, .annual] let enConfig = try XCTUnwrap(paywall.config(for: Locale(identifier: "en_US"))) expect(enConfig.title) == "Paywall"