diff --git a/RevenueCatUI/Data/TestData.swift b/RevenueCatUI/Data/TestData.swift index 2a9787e715..1af5896ef3 100644 --- a/RevenueCatUI/Data/TestData.swift +++ b/RevenueCatUI/Data/TestData.swift @@ -27,8 +27,8 @@ internal enum TestData { ) static let monthlyProduct = TestStoreProduct( localizedTitle: "Monthly", - price: 12.99, - localizedPriceString: "$12.99", + price: 6.99, + localizedPriceString: "$6.99", productIdentifier: "com.revenuecat.product_2", productType: .autoRenewableSubscription, localizedDescription: "PRO monthly", @@ -36,10 +36,21 @@ internal enum TestData { subscriptionPeriod: .init(value: 1, unit: .month), introductoryDiscount: Self.intro(7, .day) ) + static let sixMonthProduct = TestStoreProduct( + localizedTitle: "Monthly", + price: 34.99, + localizedPriceString: "$34.99", + productIdentifier: "com.revenuecat.product_5", + productType: .autoRenewableSubscription, + localizedDescription: "PRO monthly", + subscriptionGroupIdentifier: "group", + subscriptionPeriod: .init(value: 6, unit: .month), + introductoryDiscount: Self.intro(7, .day) + ) static let annualProduct = TestStoreProduct( localizedTitle: "Annual", - price: 69.49, - localizedPriceString: "$69.49", + price: 53.99, + localizedPriceString: "$53.99", productIdentifier: "com.revenuecat.product_3", productType: .autoRenewableSubscription, localizedDescription: "PRO annual", @@ -101,6 +112,12 @@ internal enum TestData { storeProduct: Self.monthlyProduct.toStoreProduct(), offeringIdentifier: Self.offeringIdentifier ) + static let sixMonthPackage = Package( + identifier: PackageType.sixMonth.identifier, + packageType: .sixMonth, + storeProduct: Self.sixMonthProduct.toStoreProduct(), + offeringIdentifier: Self.offeringIdentifier + ) static let annualPackage = Package( identifier: PackageType.annual.identifier, packageType: .annual, @@ -256,6 +273,46 @@ internal enum TestData { Self.annualPackage] ) + static let offeringWithMultiPackageHorizontalPaywall = Offering( + identifier: Self.offeringIdentifier, + serverDescription: "Offering", + metadata: [:], + paywall: .init( + template: .multiPackageHorizontal, + config: .init( + packages: [PackageType.monthly.identifier, + PackageType.sixMonth.identifier, + PackageType.annual.identifier], + defaultPackage: PackageType.sixMonth.identifier, + images: .init( + background: "background.jpg" + ), + colors: .init( + light: .init( + background: "#FFFFFF", + text1: "#111111", + callToActionBackground: "#06357D", + callToActionForeground: "#FFFFFF", + accent1: "#D4B5FC", + accent2: "#DFDFDF" + ) + ), + termsOfServiceURL: URL(string: "https://revenuecat.com/tos")! + ), + localization: .init( + title: "Get _unlimited_ access", + callToAction: "Continue", + offerDetails: "", + offerDetailsWithIntroOffer: "Includes {{ intro_duration }} **free** trial", + offerName: "{{ subscription_duration }}" + ), + assetBaseURL: Bundle.module.resourceURL ?? Bundle.module.bundleURL + ), + availablePackages: [TestData.monthlyPackage, + TestData.sixMonthPackage, + TestData.annualPackage] + ) + static let lightColors: PaywallData.Configuration.Colors = .init( background: "#FFFFFF", text1: "#000000", diff --git a/RevenueCatUI/Modifiers/ViewExtensions.swift b/RevenueCatUI/Modifiers/ViewExtensions.swift index f478532618..49e22244ac 100644 --- a/RevenueCatUI/Modifiers/ViewExtensions.swift +++ b/RevenueCatUI/Modifiers/ViewExtensions.swift @@ -20,6 +20,13 @@ extension View { } } +} + +// MARK: - Scrolling + +@available(iOS 13.0, tvOS 13.0, macOS 10.15, watchOS 6.2, *) +extension View { + @ViewBuilder func scrollable( _ axes: Axis.Set = .vertical, @@ -60,6 +67,12 @@ extension View { } } } +} + +// MARK: - Size changes + +@available(iOS 13.0, tvOS 13.0, macOS 10.15, watchOS 6.2, *) +extension View { /// Invokes the given closure whethever the view size changes. func onSizeChange(_ closure: @escaping (CGSize) -> Void) -> some View { @@ -74,9 +87,82 @@ extension View { .onPreferenceChange(ViewSizePreferenceKey.self, perform: closure) } + /// Invokes the given closure with the dimension specified by `axis` changes whenever it changes. + func onSizeChange( + _ axis: Axis, + _ closure: @escaping (CGFloat) -> Void + ) -> some View { + self + .overlay( + GeometryReader { geometry in + Color.clear + .preference( + key: ViewDimensionPreferenceKey.self, + value: axis == .horizontal + ? geometry.size.width + : geometry.size.height + ) + } + ) + .onPreferenceChange(ViewDimensionPreferenceKey.self, perform: closure) + } + } -// MARK: - +// MARK: - Rounded corners + +#if canImport(UIKit) + +@available(iOS 13.0, tvOS 13.0, macOS 10.15, watchOS 6.2, *) +extension View { + + func roundedCorner( + _ radius: CGFloat, + corners: UIRectCorner, + edgesIgnoringSafeArea edges: Edge.Set = [] + ) -> some View { + self.mask( + RoundedCorner(radius: radius, corners: corners) + .edgesIgnoringSafeArea(edges) + ) + } + +} + +@available(iOS 13.0, tvOS 13.0, macOS 10.15, watchOS 6.2, *) +private struct RoundedCorner: Shape { + + var radius: CGFloat + var corners: UIRectCorner + + func path(in rect: CGRect) -> Path { + let path = UIBezierPath(roundedRect: rect, + byRoundingCorners: self.corners, + cornerRadii: CGSize(width: self.radius, height: self.radius)) + return Path(path.cgPath) + } + +} + +#endif + +// MARK: - Preference Keys + +/// `PreferenceKey` for keeping track of a view dimension. +private struct ViewDimensionPreferenceKey: PreferenceKey { + + typealias Value = CGFloat + + static var defaultValue: Value = 10 + + static func reduce(value: inout Value, nextValue: () -> Value) { + let newValue = max(value, nextValue()) + if newValue != value { + value = newValue + } + } + +} /// `PreferenceKey` for keeping track of view size. private struct ViewSizePreferenceKey: PreferenceKey { diff --git a/RevenueCatUI/PaywallView.swift b/RevenueCatUI/PaywallView.swift index 0f3f76ca15..76146334d6 100644 --- a/RevenueCatUI/PaywallView.swift +++ b/RevenueCatUI/PaywallView.swift @@ -278,6 +278,7 @@ private extension PaywallTemplate { case .onePackageStandard: return "single" case .multiPackageBold: return "multi" case .onePackageWithFeatures: return "features" + case .multiPackageHorizontal: return "horizontal" } } diff --git a/RevenueCatUI/Templates/MultiPackageHorizontalTemplate.swift b/RevenueCatUI/Templates/MultiPackageHorizontalTemplate.swift new file mode 100644 index 0000000000..306b62e231 --- /dev/null +++ b/RevenueCatUI/Templates/MultiPackageHorizontalTemplate.swift @@ -0,0 +1,352 @@ +// +// MultiPackageHorizontalTemplate.swift +// +// +// Created by Nacho Soto on 8/1/23. +// + +import RevenueCat +import SwiftUI + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, *) +struct MultiPackageHorizontalTemplate: TemplateViewType { + + private let configuration: TemplateViewConfiguration + private var localization: [Package: ProcessedLocalizedConfiguration] + + @State + private var selectedPackage: Package + + @State + private var packageContentHeight: CGFloat = 10 + @State + private var containerWidth: CGFloat = 600 + + @Environment(\.dynamicTypeSize) + private var dynamicTypeSize + + @EnvironmentObject + private var introEligibilityViewModel: IntroEligibilityViewModel + @EnvironmentObject + private var purchaseHandler: PurchaseHandler + + init(_ configuration: TemplateViewConfiguration) { + self._selectedPackage = .init(initialValue: configuration.packages.default.content) + + self.configuration = configuration + self.localization = configuration.packages.localizationPerPackage() + } + + var body: some View { + ZStack(alignment: .bottom) { + TemplateBackgroundImageView(configuration: self.configuration) + + self.cardContent + .edgesIgnoringSafeArea(.bottom) + .frame(maxWidth: .infinity, alignment: .bottom) + .background(self.configuration.colors.backgroundColor) + #if canImport(UIKit) + .roundedCorner(Self.cornerRadius, + corners: [.topLeft, .topRight], + edgesIgnoringSafeArea: .bottom) + #endif + } + } + + @ViewBuilder + var cardContent: some View { + VStack(spacing: 20) { + Text(.init(self.selectedLocalization.title)) + .foregroundColor(self.configuration.colors.text1Color) + .font(.title.bold()) + .padding([.top, .bottom, .horizontal]) + .dynamicTypeSize(...Constants.maximumDynamicTypeSize) + + self.packages + .frame(height: self.packageContentHeight) + .scrollableIfNecessary(.horizontal) + .frame(maxWidth: .infinity) + .onSizeChange(.horizontal) { + self.containerWidth = $0 + } + + IntroEligibilityStateView( + textWithNoIntroOffer: self.selectedLocalization.offerDetails, + textWithIntroOffer: self.selectedLocalization.offerDetailsWithIntroOffer, + introEligibility: self.introEligibility[self.selectedPackage], + foregroundColor: self.configuration.colors.text1Color + ) + .font(.body.weight(.light)) + .dynamicTypeSize(...Constants.maximumDynamicTypeSize) + + self.subscribeButton + .padding(.horizontal) + + FooterView(configuration: self.configuration.configuration, + color: self.configuration.colors.callToActionBackgroundColor, + bold: false, + purchaseHandler: self.purchaseHandler) + .frame(maxWidth: .infinity) + } + .animation(Constants.fastAnimation, value: self.selectedPackage) + .multilineTextAlignment(.center) + .overlay { + self.packageHeightCalculation + } + } + + private var packages: some View { + HStack(spacing: self.packageHorizontalSpacing) { + ForEach(self.configuration.packages.all, id: \.content.id) { package in + let isSelected = self.selectedPackage === package.content + + Button { + self.selectedPackage = package.content + } label: { + PackageButton(configuration: self.configuration, + package: package, + localization: self.localization(for: package.content), + selected: isSelected, + packageWidth: self.packageWidth, + desiredHeight: self.packageContentHeight) + .contentShape(Rectangle()) + } + .buttonStyle(PackageButtonStyle(isSelected: isSelected)) + } + } + .padding(.horizontal, self.packageHorizontalSpacing) + } + + private var subscribeButton: some View { + PurchaseButton( + package: self.selectedPackage, + colors: self.configuration.colors, + localization: self.selectedLocalization, + introEligibility: self.introEligibility[self.selectedPackage], + mode: self.configuration.mode, + purchaseHandler: self.purchaseHandler + ) + } + + /// Proxy views to calculate the largest package view + private var packageHeightCalculation: some View { + ZStack { + ForEach(self.configuration.packages.all, id: \.content.id) { package in + PackageButton(configuration: self.configuration, + package: package, + localization: self.localization(for: package.content), + selected: false, + packageWidth: self.packageWidth, + desiredHeight: nil) + .background(.red) + .offset(x: CGFloat(Int.random(in: -200...200))) + .onSizeChange(.vertical) { + if $0 > self.packageContentHeight { + self.packageContentHeight = $0 + } + } + } + } + .onChange(of: self.dynamicTypeSize) { _ in self.packageContentHeight = 0 } + .hidden() + } + + private var packageWidth: CGFloat { + let packages = self.packagesToDisplay + return self.containerWidth / packages - self.packageHorizontalSpacing * (packages - 1) + } + + // MARK: - + + private var introEligibility: [Package: IntroEligibilityStatus] { + return self.introEligibilityViewModel.allEligibility + } + + fileprivate static let cornerRadius: CGFloat = 16 + fileprivate static let verticalPadding: CGFloat = 10 + + @ScaledMetric(relativeTo: .title2) + private var packageHorizontalSpacing: CGFloat = 8 + + private var packagesToDisplay: CGFloat { + let desiredCount = { + if self.dynamicTypeSize < .xxLarge { + return 3.5 + } else if self.dynamicTypeSize < .accessibility3 { + return 2.5 + } else { + return 1.5 + } + }() + + // If there are fewer, use actual count + return min(desiredCount, CGFloat(self.configuration.packages.all.count)) + } + +} + +// MARK: - Views + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, *) +private struct PackageButton: View { + + var configuration: TemplateViewConfiguration + var package: TemplateViewConfiguration.Package + var localization: ProcessedLocalizedConfiguration + var selected: Bool + var packageWidth: CGFloat + var desiredHeight: CGFloat? + + @State + private var discountLabelHeight: CGFloat = 10 + + @Environment(\.locale) + private var locale + + var body: some View { + self.buttonTitle(self.package) + .frame(width: self.packageWidth) + .background { // Stroke + RoundedRectangle(cornerRadius: MultiPackageHorizontalTemplate.cornerRadius) + .stroke( + self.selected + ? self.configuration.colors.accent1Color + : self.configuration.colors.accent2Color, + lineWidth: Self.borderWidth + ) + .frame(width: self.packageWidth) + .frame(maxHeight: .infinity) + .padding(Self.borderWidth) + } + .background { // Background + RoundedRectangle(cornerRadius: MultiPackageHorizontalTemplate.cornerRadius) + .foregroundStyle(self.configuration.colors.backgroundColor) + .frame(width: self.packageWidth) + .padding(Self.borderWidth) + .frame(maxHeight: .infinity) + } + .background { // Discount overlay + if let discount = self.package.discountRelativeToMostExpensivePerMonth { + self.discountOverlay(discount) + } else { + self.discountOverlay(0) + .hidden() + } + } + .padding(.top, self.discountOverlayHeight) + .frame(height: self.desiredHeight) + .multilineTextAlignment(.center) + .accessibilityElement(children: .combine) + } + + private func buttonTitle( + _ package: TemplateViewConfiguration.Package + ) -> some View { + VStack(spacing: Self.labelVerticalSeparation) { + self.offerName + + Text(self.package.content.localizedPrice) + .font(.title2.weight(.semibold)) + .lineLimit(1) + .minimumScaleFactor(0.7) + } + .padding(.vertical, Self.labelVerticalSeparation * 2.0) + .padding(.horizontal) + .foregroundColor(self.configuration.colors.text1Color) + } + + @ViewBuilder + private var offerName: some View { + Group { + if let offerName = self.localization.offerName { + let components = offerName.split(separator: " ", maxSplits: 2) + if components.count == 2 { + VStack { + Text(components[0]) + .font(.title.bold()) + + Text(components[1]) + .font(.title3) + } + } else { + Text(offerName) + } + } else { + Text(self.package.content.productName) + } + } + .font(.title3.weight(.regular)) + } + + private func discountOverlay(_ discount: Double) -> some View { + ZStack(alignment: .top) { + RoundedRectangle(cornerRadius: MultiPackageHorizontalTemplate.cornerRadius) + .foregroundStyle( + self.selected + ? self.configuration.colors.accent1Color + : self.configuration.colors.accent2Color + ) + + Text(Localization.localized(discount: discount, locale: self.locale)) + .textCase(.uppercase) + .foregroundColor(self.configuration.colors.text1Color) + .font(.caption.weight(.semibold)) + .lineLimit(1) + .minimumScaleFactor(0.5) + .padding(.horizontal, 2) + .onSizeChange(.vertical) { + self.discountLabelHeight = $0 + } + .offset( + y: (self.discountOverlayHeight - self.discountLabelHeight) / 2.0 + + Self.borderWidth + ) + } + .offset(y: self.discountOverlayHeight * -1) + .frame(width: self.packageWidth + Self.borderWidth) + } + + private static let labelVerticalSeparation: CGFloat = 5 + private static let borderWidth: CGFloat = 2 + + private var discountOverlayHeight: CGFloat { + return self.discountLabelHeight + MultiPackageHorizontalTemplate.verticalPadding + } + +} + +// MARK: - Extensions + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, *) +private extension MultiPackageHorizontalTemplate { + + func localization(for package: Package) -> ProcessedLocalizedConfiguration { + // Because of how packages are constructed this is known to exist + return self.localization[package]! + } + + var selectedLocalization: ProcessedLocalizedConfiguration { + return self.localization(for: self.selectedPackage) + } + +} + +// MARK: - + +#if DEBUG + +@available(iOS 16.0, macOS 13.0, tvOS 16.0, *) +@available(watchOS, unavailable) +@available(macOS, unavailable) +@available(macCatalyst, unavailable) +struct MultiPackageHorizontalTemplate_Previews: PreviewProvider { + + static var previews: some View { + PreviewableTemplate(offering: TestData.offeringWithMultiPackageHorizontalPaywall) { + MultiPackageHorizontalTemplate($0) + } + } + +} + +#endif diff --git a/RevenueCatUI/Templates/TemplateViewType.swift b/RevenueCatUI/Templates/TemplateViewType.swift index 2a1bb0bd1a..5b9505b49f 100644 --- a/RevenueCatUI/Templates/TemplateViewType.swift +++ b/RevenueCatUI/Templates/TemplateViewType.swift @@ -18,6 +18,7 @@ private extension PaywallTemplate { case .onePackageStandard: return .single case .multiPackageBold: return .multiple case .onePackageWithFeatures: return .single + case .multiPackageHorizontal: return .multiple } } @@ -84,6 +85,8 @@ extension PaywallData { MultiPackageBoldTemplate(configuration) case .onePackageWithFeatures: OnePackageWithFeaturesTemplate(configuration) + case .multiPackageHorizontal: + MultiPackageHorizontalTemplate(configuration) } } diff --git a/Sources/Paywalls/PaywallTemplate.swift b/Sources/Paywalls/PaywallTemplate.swift index eedd452d69..e548739727 100644 --- a/Sources/Paywalls/PaywallTemplate.swift +++ b/Sources/Paywalls/PaywallTemplate.swift @@ -20,6 +20,7 @@ public enum PaywallTemplate: String { case onePackageStandard = "one_package_standard" case multiPackageBold = "multi_package_bold" case onePackageWithFeatures = "one_package_with_features" + case multiPackageHorizontal = "multi_package_horizontal" // swiftlint:enable missing_docs diff --git a/Tests/APITesters/SwiftAPITester/SwiftAPITester/PaywallAPI.swift b/Tests/APITesters/SwiftAPITester/SwiftAPITester/PaywallAPI.swift index 276e20f8f2..fd283c6534 100644 --- a/Tests/APITesters/SwiftAPITester/SwiftAPITester/PaywallAPI.swift +++ b/Tests/APITesters/SwiftAPITester/SwiftAPITester/PaywallAPI.swift @@ -152,6 +152,8 @@ func checkPaywallTemplate(_ template: PaywallTemplate) { break case .onePackageWithFeatures: break + case .multiPackageHorizontal: + break @unknown default: break } diff --git a/Tests/RevenueCatUITests/Data/TemplateViewConfigurationTests.swift b/Tests/RevenueCatUITests/Data/TemplateViewConfigurationTests.swift index 208a37cc7d..587bd74061 100644 --- a/Tests/RevenueCatUITests/Data/TemplateViewConfigurationTests.swift +++ b/Tests/RevenueCatUITests/Data/TemplateViewConfigurationTests.swift @@ -105,7 +105,7 @@ class TemplateViewConfigurationCreationTests: BaseTemplateViewConfigurationTests let annual = packages[0] expect(annual.content) === TestData.annualPackage expect(annual.discountRelativeToMostExpensivePerMonth) - .to(beCloseTo(0.55, within: 0.01)) + .to(beCloseTo(0.36, within: 0.01)) Self.verifyLocalizationWasProcessed(annual.localization, for: TestData.annualPackage) let monthly = packages[1] diff --git a/Tests/RevenueCatUITests/Templates/MultiPackageHorizontalPaywallViewTests.swift b/Tests/RevenueCatUITests/Templates/MultiPackageHorizontalPaywallViewTests.swift new file mode 100644 index 0000000000..1d894c124d --- /dev/null +++ b/Tests/RevenueCatUITests/Templates/MultiPackageHorizontalPaywallViewTests.swift @@ -0,0 +1,38 @@ +import Nimble +import RevenueCat +@testable import RevenueCatUI +import SnapshotTesting + +#if !os(macOS) + +@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) +class MultiPackageHorizontalPaywallViewTests: BaseSnapshotTest { + + func testSamplePaywall() { + PaywallView(offering: Self.offering.withLocalImages, + introEligibility: Self.eligibleChecker, + purchaseHandler: Self.purchaseHandler) + .snapshot(size: Self.fullScreenSize) + } + + func testLargeDynamicType() { + PaywallView(offering: Self.offering.withLocalImages, + introEligibility: Self.eligibleChecker, + purchaseHandler: Self.purchaseHandler) + .environment(\.dynamicTypeSize, .xxLarge) + .snapshot(size: Self.fullScreenSize) + } + + func testLargerDynamicType() { + PaywallView(offering: Self.offering.withLocalImages, + introEligibility: Self.eligibleChecker, + purchaseHandler: Self.purchaseHandler) + .environment(\.dynamicTypeSize, .accessibility2) + .snapshot(size: Self.fullScreenSize) + } + + private static let offering = TestData.offeringWithMultiPackageHorizontalPaywall + +} + +#endif diff --git a/Tests/TestingApps/SimpleApp/SimpleApp/SamplePaywalls.swift b/Tests/TestingApps/SimpleApp/SimpleApp/SamplePaywalls.swift index 0009b9f8a6..5c328c2b8b 100644 --- a/Tests/TestingApps/SimpleApp/SimpleApp/SamplePaywalls.swift +++ b/Tests/TestingApps/SimpleApp/SimpleApp/SamplePaywalls.swift @@ -16,6 +16,7 @@ final class SamplePaywallLoader { self.packages = [ Self.weeklyPackage, Self.monthlyPackage, + Self.sixMonthPackage, Self.annualPackage, Self.lifetimePackage ] @@ -49,6 +50,8 @@ final class SamplePaywallLoader { return Self.multiPackageBoldTemplate() case .onePackageWithFeatures: return Self.onePackageWithFeaturesTemplate() + case .multiPackageHorizontal: + return Self.multiPackageHorizontalTemplate() } } @@ -70,6 +73,12 @@ private extension SamplePaywallLoader { storeProduct: monthlyProduct.toStoreProduct(), offeringIdentifier: offeringIdentifier ) + static let sixMonthPackage = Package( + identifier: Package.string(from: .sixMonth)!, + packageType: .sixMonth, + storeProduct: sixMonthProduct.toStoreProduct(), + offeringIdentifier: offeringIdentifier + ) static let annualPackage = Package( identifier: Package.string(from: .annual)!, packageType: .annual, @@ -95,8 +104,8 @@ private extension SamplePaywallLoader { ) static let monthlyProduct = TestStoreProduct( localizedTitle: "Monthly", - price: 12.99, - localizedPriceString: "$12.99", + price: 6.99, + localizedPriceString: "$6.99", productIdentifier: "com.revenuecat.product_2", productType: .autoRenewableSubscription, localizedDescription: "PRO monthly", @@ -112,10 +121,29 @@ private extension SamplePaywallLoader { type: .introductory ) ) + static let sixMonthProduct = TestStoreProduct( + localizedTitle: "Monthly", + price: 34.99, + localizedPriceString: "$34.99", + productIdentifier: "com.revenuecat.product_4", + productType: .autoRenewableSubscription, + localizedDescription: "PRO monthly", + subscriptionGroupIdentifier: "group", + subscriptionPeriod: .init(value: 6, unit: .month), + introductoryDiscount: .init( + identifier: "intro", + price: 0, + localizedPriceString: "$0.00", + paymentMode: .freeTrial, + subscriptionPeriod: .init(value: 7, unit: .day), + numberOfPeriods: 1, + type: .introductory + ) + ) static let annualProduct = TestStoreProduct( localizedTitle: "Annual", - price: 69.49, - localizedPriceString: "$69.49", + price: 53.99, + localizedPriceString: "$53.99", productIdentifier: "com.revenuecat.product_3", productType: .autoRenewableSubscription, localizedDescription: "PRO annual", @@ -263,6 +291,37 @@ private extension SamplePaywallLoader { ) } + static func multiPackageHorizontalTemplate() -> PaywallData { + return .init( + template: .multiPackageHorizontal, + config: .init( + packages: Array([.monthly, .sixMonth, .annual]) + .map { Package.string(from: $0)! }, + defaultPackage: Package.string(from: .sixMonth)!, + images: .init(background: "300883_1690710097.jpg"), + colors: .init( + light: .init( + background: "#FFFFFF", + text1: "#111111", + callToActionBackground: "#06357D", + callToActionForeground: "#FFFFFF", + accent1: "#D4B5FC", + accent2: "#DFDFDF" + ) + ), + termsOfServiceURL: URL(string: "https://revenuecat.com/tos")! + ), + localization: .init( + title: "Get _unlimited_ access", + callToAction: "Continue", + offerDetails: nil, + offerDetailsWithIntroOffer: "Includes {{ intro_duration }} **free** trial", + offerName: "{{ subscription_duration }}" + ), + assetBaseURL: Self.paywallAssetBaseURL + ) + } + } private extension SamplePaywallLoader { diff --git a/Tests/TestingApps/SimpleApp/SimpleApp/SimpleApp.swift b/Tests/TestingApps/SimpleApp/SimpleApp/SimpleApp.swift index 3fbc442efa..7bde59ffb7 100644 --- a/Tests/TestingApps/SimpleApp/SimpleApp/SimpleApp.swift +++ b/Tests/TestingApps/SimpleApp/SimpleApp/SimpleApp.swift @@ -20,6 +20,7 @@ struct SimpleApp: App { Purchases.configure( with: .init(withAPIKey: Configuration.effectiveApiKey) + .with(entitlementVerificationMode: .informational) .with(usesStoreKit2IfAvailable: true) ) } diff --git a/Tests/TestingApps/SimpleApp/SimpleApp/Views/SamplePaywallsList.swift b/Tests/TestingApps/SimpleApp/SimpleApp/Views/SamplePaywallsList.swift index 64b2a6af18..d094489942 100644 --- a/Tests/TestingApps/SimpleApp/SimpleApp/Views/SamplePaywallsList.swift +++ b/Tests/TestingApps/SimpleApp/SimpleApp/Views/SamplePaywallsList.swift @@ -5,8 +5,10 @@ // Created by Nacho Soto on 7/27/23. // +#if DEBUG + import RevenueCat -import RevenueCatUI +@testable import RevenueCatUI import SwiftUI struct SamplePaywallsList: View { @@ -19,9 +21,13 @@ struct SamplePaywallsList: View { .sheet(item: self.$display) { display in switch display { case let .template(template): - PaywallView(offering: Self.loader.offering(for: template)) + PaywallView(offering: Self.loader.offering(for: template), + introEligibility: Self.introEligibility, + purchaseHandler: .default()) case .defaultTemplate: - PaywallView(offering: Self.loader.offeringWithDefaultPaywall()) + PaywallView(offering: Self.loader.offeringWithDefaultPaywall(), + introEligibility: Self.introEligibility, + purchaseHandler: .default()) } } .navigationTitle("Paywalls") @@ -52,6 +58,18 @@ struct SamplePaywallsList: View { } private static let loader: SamplePaywallLoader = .init() + private static let introEligibility: TrialOrIntroEligibilityChecker = .init { packages in + return Dictionary( + uniqueKeysWithValues: Set(packages) + .map { package in + let result: IntroEligibilityStatus = package.storeProduct.hasIntroDiscount + ? Bool.random() ? .eligible : .ineligible + : .noIntroOfferExists + + return (package, result) + } + ) + } } @@ -103,6 +121,8 @@ extension PaywallTemplate { return "Multi package bold" case .onePackageWithFeatures: return "One package with features" + case .multiPackageHorizontal: + return "Multi package horizontal" } } @@ -119,3 +139,5 @@ struct SamplePaywallsList_Previews: PreviewProvider { } #endif + +#endif