diff --git a/RevenueCatUI/Data/Constants.swift b/RevenueCatUI/Data/Constants.swift index 4dcc65e1dc..246842a893 100644 --- a/RevenueCatUI/Data/Constants.swift +++ b/RevenueCatUI/Data/Constants.swift @@ -10,8 +10,11 @@ import SwiftUI @available(iOS 15.0, macOS 12.0, tvOS 15.0, *) enum Constants { - static let defaultAnimation: Animation = .easeIn(duration: 0.2) - static let fastAnimation: Animation = .easeIn(duration: 0.1) + static let defaultAnimation: Animation = .easeInOut(duration: 0.2) + static let fastAnimation: Animation = .easeInOut(duration: 0.1) + static let displayAllPlansAnimation: Animation = .easeInOut(duration: 0.2) + + static let defaultCornerRadius: CGFloat = 20 /// For UI elements that wouldn't make sense to keep scaling up forever static let maximumDynamicTypeSize: DynamicTypeSize = .accessibility3 diff --git a/RevenueCatUI/Data/PaywallViewMode+Extensions.swift b/RevenueCatUI/Data/PaywallViewMode+Extensions.swift new file mode 100644 index 0000000000..c71dbd260b --- /dev/null +++ b/RevenueCatUI/Data/PaywallViewMode+Extensions.swift @@ -0,0 +1,49 @@ +// +// PaywallViewMode+Extensions.swift +// +// +// Created by Nacho Soto on 8/9/23. +// + +import RevenueCat + +extension PaywallViewMode { + + var displayAllPlansByDefault: Bool { + switch self { + case .fullScreen: return true + case .card: return true + case .condensedCard: return false + } + } + + var displayAllPlansButton: Bool { + switch self { + case .fullScreen: return false + case .card: return false + case .condensedCard: return true + } + } + + var shouldDisplayIcon: Bool { + switch self { + case .fullScreen: return true + case .card, .condensedCard: return false + } + } + + var shouldDisplayText: Bool { + switch self { + case .fullScreen: return true + case .card, .condensedCard: return false + } + } + + var shouldDisplayFeatures: Bool { + switch self { + case .fullScreen: return true + case .card, .condensedCard: return false + } + } + +} diff --git a/RevenueCatUI/Data/TemplateViewConfiguration.swift b/RevenueCatUI/Data/TemplateViewConfiguration.swift index 9bc05ed089..205ed0e115 100644 --- a/RevenueCatUI/Data/TemplateViewConfiguration.swift +++ b/RevenueCatUI/Data/TemplateViewConfiguration.swift @@ -91,24 +91,6 @@ extension TemplateViewConfiguration.PackageConfiguration { } -// MARK: - Helpers - -@available(iOS 15.0, macOS 12.0, tvOS 15.0, *) -extension TemplateViewConfiguration.PackageConfiguration { - - /// - Returns: a dictionary for all localizations keyed by each package. - func localizationPerPackage() -> [Package: ProcessedLocalizedConfiguration] { - return .init( - self.all - .lazy - .map { ($0.content, $0.localization) }, - // Ignore duplicates - uniquingKeysWith: { first, _ in first } - ) - } - -} - // MARK: - Creation @available(iOS 15.0, macOS 12.0, tvOS 15.0, *) diff --git a/RevenueCatUI/Data/TestData.swift b/RevenueCatUI/Data/TestData.swift index 4e7f070fc4..630971a351 100644 --- a/RevenueCatUI/Data/TestData.swift +++ b/RevenueCatUI/Data/TestData.swift @@ -216,7 +216,6 @@ internal enum TestData { ) ), blurredBackgroundImage: true, - termsOfServiceURL: URL(string: "https://revenuecat.com/tos")!, privacyURL: URL(string: "https://revenuecat.com/tos")! ), localization: Self.localization2, @@ -253,7 +252,7 @@ internal enum TestData { callToAction: "Start", callToActionWithIntroOffer: "Start your {{ sub_offer_duration }} free", offerDetails: "Only {{ price }} per {{ sub_period }}", - offerDetailsWithIntroOffer: "First {{ sub_offer_duration }} free,\n" + + offerDetailsWithIntroOffer: "First {{ sub_offer_duration }} free, " + "then {{ total_price_and_per_month }}", features: [ .init(title: "Today", diff --git a/RevenueCatUI/Helpers/PreviewHelpers.swift b/RevenueCatUI/Helpers/PreviewHelpers.swift index 59aba1e2c4..707d3aed0d 100644 --- a/RevenueCatUI/Helpers/PreviewHelpers.swift +++ b/RevenueCatUI/Helpers/PreviewHelpers.swift @@ -53,12 +53,13 @@ struct PreviewableTemplate: View { init( offering: Offering, + mode: PaywallViewMode = .default, presentInSheet: Bool = false, creator: @escaping Creator ) { self.configuration = offering.paywall!.configuration( for: offering, - mode: .fullScreen, + mode: mode, fonts: DefaultPaywallFontProvider(), locale: .current ) @@ -90,6 +91,8 @@ struct PreviewableTemplate: View { for: configuration.packages ) } + .previewDisplayName("\(configuration.mode)") + .previewLayout(configuration.mode.layout) case let .failure(error): DebugErrorView("Invalid configuration: \(error)", @@ -99,4 +102,17 @@ struct PreviewableTemplate: View { } +@available(iOS 15.0, macOS 12.0, tvOS 15.0, *) +private extension PaywallViewMode { + + var layout: PreviewLayout { + switch self { + case .fullScreen: return .device + case .card: return .fixed(width: 400, height: 280) + case .condensedCard: return .fixed(width: 400, height: 150) + } + } + +} + #endif diff --git a/RevenueCatUI/Modifiers/CardHidingModifier.swift b/RevenueCatUI/Modifiers/CardHidingModifier.swift new file mode 100644 index 0000000000..383ee4d00c --- /dev/null +++ b/RevenueCatUI/Modifiers/CardHidingModifier.swift @@ -0,0 +1,36 @@ +// +// CardHidingModifier.swift +// +// +// Created by Nacho Soto on 8/9/23. +// + +import RevenueCat +import SwiftUI + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, *) +extension View { + + func hideCardContent(_ hide: Bool, _ offset: CGFloat) -> some View { + return self.modifier(CardHidingModifier(hide: hide, offset: offset)) + } + +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, *) +private struct CardHidingModifier: ViewModifier { + + var hide: Bool + var offset: CGFloat + + func body(content: Content) -> some View { + content + .opacity(self.hide ? 0 : 1) + .offset(y: self.hide ? self.offset : 0) + .frame(height: self.hide ? 0 : nil) + .blur(radius: self.hide ? Self.blurRadius : 0) + } + + private static let blurRadius: CGFloat = 20 + +} diff --git a/RevenueCatUI/PaywallView.swift b/RevenueCatUI/PaywallView.swift index ca83e19d1b..967fa94c36 100644 --- a/RevenueCatUI/PaywallView.swift +++ b/RevenueCatUI/PaywallView.swift @@ -90,7 +90,7 @@ public struct PaywallView: View { purchaseHandler: purchaseHandler) .transition(Self.transition) } else { - LoadingPaywallView() + LoadingPaywallView(mode: self.mode) .transition(Self.transition) .task { do { @@ -202,38 +202,15 @@ struct LoadedOfferingPaywallView: View { .environmentObject(self.purchaseHandler) .preference(key: PurchasedCustomerInfoPreferenceKey.self, value: self.purchaseHandler.purchasedCustomerInfo) - .hidden(if: self.shouldHidePaywall) .disabled(self.purchaseHandler.actionInProgress) - if let aspectRatio = self.mode.aspectRatio { - view.aspectRatio(aspectRatio, contentMode: .fit) - } else { - view - } - } - - private var shouldHidePaywall: Bool { switch self.mode { case .fullScreen: - return false - - case .card, .banner: - return self.purchaseHandler.purchased - } - } - -} - -// MARK: - Extensions - -@available(iOS 15.0, macOS 12.0, tvOS 15.0, *) -private extension PaywallViewMode { + view - var aspectRatio: CGFloat? { - switch self { - case .fullScreen: return nil - case .card: return 1 - case .banner: return 8 + case .card, .condensedCard: + view + .fixedSize(horizontal: false, vertical: true) } } @@ -288,7 +265,7 @@ private extension PaywallViewMode { var layout: PreviewLayout { switch self { case .fullScreen: return .device - case .card, .banner: return .sizeThatFits + case .card, .condensedCard: return .sizeThatFits } } diff --git a/RevenueCatUI/Resources/en.lproj/Localizable.strings b/RevenueCatUI/Resources/en.lproj/Localizable.strings index 2321577f2f..e4bcc20944 100644 --- a/RevenueCatUI/Resources/en.lproj/Localizable.strings +++ b/RevenueCatUI/Resources/en.lproj/Localizable.strings @@ -1,4 +1,5 @@ "OK" = "OK"; +"All plans" = "All plans"; "Privacy" = "Privacy"; "Privacy policy" = "Privacy policy"; "Purchases restored successfully!" = "Purchases restored successfully!"; diff --git a/RevenueCatUI/Resources/es.lproj/Localizable.strings b/RevenueCatUI/Resources/es.lproj/Localizable.strings index 8303af207c..7989dfb65e 100644 --- a/RevenueCatUI/Resources/es.lproj/Localizable.strings +++ b/RevenueCatUI/Resources/es.lproj/Localizable.strings @@ -1,4 +1,5 @@ "OK" = "OK"; +"All plans" = "Planes"; "Privacy" = "Privacidad"; "Privacy policy" = "PolĂ­tica de privacidad"; "Purchases restored successfully!" = "Compras restauradas!"; diff --git a/RevenueCatUI/Templates/Template1View.swift b/RevenueCatUI/Templates/Template1View.swift index e8e1e6afc4..7963b80df8 100644 --- a/RevenueCatUI/Templates/Template1View.swift +++ b/RevenueCatUI/Templates/Template1View.swift @@ -19,14 +19,12 @@ struct Template1View: TemplateViewType { } var body: some View { - VStack(spacing: self.configuration.mode.verticalSpacing) { + VStack { self.scrollableContent .scrollableIfNecessary() .scrollBounceBehaviorBasedOnSize() - if case .fullScreen = self.configuration.mode { - Spacer() - } + Spacer() IntroEligibilityStateView( textWithNoIntroOffer: self.localization.offerDetails, @@ -34,47 +32,45 @@ struct Template1View: TemplateViewType { introEligibility: self.introEligibility, foregroundColor: self.configuration.colors.text1Color ) - .font(self.font(for: self.configuration.mode.offerDetailsFont)) + .font(self.font(for: .callout)) .multilineTextAlignment(.center) .padding(.horizontal) self.button .padding(.horizontal) - if case .fullScreen = self.configuration.mode { - FooterView(configuration: self.configuration, - purchaseHandler: self.purchaseHandler) - } + FooterView(configuration: self.configuration, + purchaseHandler: self.purchaseHandler) } } @ViewBuilder private var scrollableContent: some View { - VStack(spacing: self.configuration.mode.verticalSpacing) { - self.headerImage - - Group { - Text(.init(self.localization.title)) - .font(self.font(for: self.configuration.mode.titleFont)) - .fontWeight(.heavy) - .padding( - self.configuration.mode.displaySubtitle - ? .bottom - : [] - ) - - if self.configuration.mode.displaySubtitle, let subtitle = self.localization.subtitle { - Text(.init(subtitle)) - .font(self.font(for: self.configuration.mode.subtitleFont)) - } + VStack { + if self.configuration.mode.shouldDisplayIcon { + self.headerImage } - .padding(.horizontal, 20) - Spacer() + if self.configuration.mode.shouldDisplayText { + Group { + Text(.init(self.localization.title)) + .font(self.font(for: .largeTitle)) + .fontWeight(.heavy) + .padding(.bottom) + + if let subtitle = self.localization.subtitle { + Text(.init(subtitle)) + .font(self.font(for: .subheadline)) + } + } + .padding(.horizontal, 20) + + Spacer() + } } .foregroundColor(self.configuration.colors.text1Color) .multilineTextAlignment(.center) - .edgesIgnoringSafeArea(self.configuration.mode.isFullScreen ? .top : []) + .edgesIgnoringSafeArea(.top) } @ViewBuilder @@ -88,31 +84,16 @@ struct Template1View: TemplateViewType { @ViewBuilder private var headerImage: some View { - switch self.configuration.mode { - case .fullScreen: - self.asyncImage - .modifier(CircleMaskModifier()) - - Spacer() + self.asyncImage + .modifier(CircleMaskModifier()) - case .card: - self.asyncImage - .clipShape( - RoundedRectangle(cornerRadius: 5, style: .continuous) - ) - - Spacer() - - case .banner: - EmptyView() - } + Spacer() } @ViewBuilder private var button: some View { PurchaseButton( - package: self.configuration.packages.single.content, - localization: self.localization, + package: self.configuration.packages.single, configuration: self.configuration, introEligibility: self.introEligibility, purchaseHandler: self.purchaseHandler @@ -131,47 +112,6 @@ struct Template1View: TemplateViewType { // MARK: - Extensions -@available(iOS 15.0, macOS 12.0, tvOS 15.0, *) -private extension PaywallViewMode { - - var verticalSpacing: CGFloat? { - switch self { - case .fullScreen, .card: return nil // Default value - case .banner: return 4 - } - } - - var titleFont: Font.TextStyle { - switch self { - case .fullScreen: return .largeTitle - case .card: return .title - case .banner: return .headline - } - } - - var subtitleFont: Font.TextStyle { - switch self { - case .fullScreen: return .subheadline - case .card, .banner: return .callout - } - } - - var displaySubtitle: Bool { - switch self { - case .fullScreen, .card: return true - case .banner: return false - } - } - - var offerDetailsFont: Font.TextStyle { - switch self { - case .fullScreen: return .callout - case .card, .banner: return .caption - } - } - -} - @available(iOS 15.0, macOS 12.0, tvOS 15.0, *) private struct CircleMaskModifier: ViewModifier { diff --git a/RevenueCatUI/Templates/Template2View.swift b/RevenueCatUI/Templates/Template2View.swift index aea929a283..e12801d451 100644 --- a/RevenueCatUI/Templates/Template2View.swift +++ b/RevenueCatUI/Templates/Template2View.swift @@ -6,10 +6,15 @@ import SwiftUI struct Template2View: TemplateViewType { let configuration: TemplateViewConfiguration - private var localization: [Package: ProcessedLocalizedConfiguration] @State - private var selectedPackage: Package + private var selectedPackage: TemplateViewConfiguration.Package + + @State + private var displayingAllPlans: Bool + + @State + private var containerHeight: CGFloat = 10 @EnvironmentObject private var introEligibilityViewModel: IntroEligibilityViewModel @@ -17,10 +22,9 @@ struct Template2View: TemplateViewType { private var purchaseHandler: PurchaseHandler init(_ configuration: TemplateViewConfiguration) { - self._selectedPackage = .init(initialValue: configuration.packages.default.content) - + self._selectedPackage = .init(initialValue: configuration.packages.default) self.configuration = configuration - self.localization = configuration.packages.localizationPerPackage() + self._displayingAllPlans = .init(initialValue: configuration.mode.displayAllPlansByDefault) } var body: some View { @@ -37,13 +41,17 @@ struct Template2View: TemplateViewType { self.scrollableContent .scrollableIfNecessary() + if self.configuration.mode.shouldDisplayInlineOfferDetails, + !self.displayingAllPlans { + self.offerDetails(package: self.selectedPackage, selected: false) + } + self.subscribeButton .padding(.horizontal) - if case .fullScreen = self.configuration.mode { - FooterView(configuration: self.configuration, - purchaseHandler: self.purchaseHandler) - } + FooterView(configuration: self.configuration, + purchaseHandler: self.purchaseHandler, + displayingAllPlans: self.$displayingAllPlans) } .animation(Constants.fastAnimation, value: self.selectedPackage) .multilineTextAlignment(.center) @@ -52,27 +60,35 @@ struct Template2View: TemplateViewType { private var scrollableContent: some View { VStack { - Spacer() - - self.iconImage - - Spacer() - - Text(.init(self.selectedLocalization.title)) - .foregroundColor(self.configuration.colors.text1Color) - .font(self.font(for: .largeTitle).bold()) + if self.configuration.mode.shouldDisplayIcon { + Spacer() + self.iconImage + Spacer() + } - Spacer() + if self.configuration.mode.shouldDisplayText { + Text(.init(self.selectedLocalization.title)) + .foregroundColor(self.configuration.colors.text1Color) + .font(self.font(for: .largeTitle).bold()) - Text(.init(self.selectedLocalization.subtitle ?? "")) - .foregroundColor(self.configuration.colors.text1Color) - .font(self.font(for: .title3)) + Spacer() - Spacer() + Text(.init(self.selectedLocalization.subtitle ?? "")) + .foregroundColor(self.configuration.colors.text1Color) + .font(self.font(for: .title3)) - self.packages + Spacer() + } - Spacer() + if self.configuration.mode.shouldDisplayPackages { + self.packages + Spacer() + } else { + self.packages + .padding(.vertical) + .onSizeChange(.vertical) { if $0 > 0 { self.containerHeight = $0 } } + .hideCardContent(!self.displayingAllPlans, self.containerHeight) + } } .padding(.horizontal) .frame(maxHeight: .infinity) @@ -81,10 +97,10 @@ struct Template2View: TemplateViewType { private var packages: some View { VStack(spacing: 8) { ForEach(self.configuration.packages.all, id: \.content.id) { package in - let isSelected = self.selectedPackage === package.content + let isSelected = self.selectedPackage.content === package.content Button { - self.selectedPackage = package.content + self.selectedPackage = package } label: { self.packageButton(package, selected: isSelected) .contentShape(Rectangle()) @@ -99,15 +115,7 @@ struct Template2View: TemplateViewType { VStack(alignment: Self.packageButtonAlignment.horizontal, spacing: 5) { self.packageButtonTitle(package, selected: selected) - IntroEligibilityStateView( - textWithNoIntroOffer: package.localization.offerDetails, - textWithIntroOffer: package.localization.offerDetailsWithIntroOffer, - introEligibility: self.introEligibility[package.content], - foregroundColor: self.textColor(selected), - alignment: Self.packageButtonAlignment - ) - .fixedSize(horizontal: false, vertical: true) - .font(self.font(for: .body)) + self.offerDetails(package: package, selected: selected) } .font(self.font(for: .body).weight(.medium)) .padding() @@ -122,11 +130,13 @@ struct Template2View: TemplateViewType { } } .background { + let view = RoundedRectangle(cornerRadius: Self.cornerRadius, style: .continuous) + if selected { - RoundedRectangle(cornerRadius: Self.cornerRadius, style: .continuous) + view .foregroundColor(self.selectedBackgroundColor) } else { - RoundedRectangle(cornerRadius: Self.cornerRadius, style: .continuous) + view .foregroundStyle(.thinMaterial) } } @@ -148,11 +158,23 @@ struct Template2View: TemplateViewType { } } - Text(self.localization(for: package.content).offerName ?? package.content.productName) + Text(package.localization.offerName ?? package.content.productName) } .foregroundColor(self.textColor(selected)) } + private func offerDetails(package: TemplateViewConfiguration.Package, selected: Bool) -> some View { + IntroEligibilityStateView( + textWithNoIntroOffer: package.localization.offerDetails, + textWithIntroOffer: package.localization.offerDetailsWithIntroOffer, + introEligibility: self.introEligibility[package.content], + foregroundColor: self.textColor(selected), + alignment: Self.packageButtonAlignment + ) + .fixedSize(horizontal: false, vertical: true) + .font(self.font(for: .body)) + } + private func textColor(_ selected: Bool) -> Color { return selected ? self.configuration.colors.accent1Color @@ -162,9 +184,8 @@ struct Template2View: TemplateViewType { private var subscribeButton: some View { PurchaseButton( package: self.selectedPackage, - localization: self.selectedLocalization, configuration: self.configuration, - introEligibility: self.introEligibility[self.selectedPackage], + introEligibility: self.introEligibility[self.selectedPackage.content], purchaseHandler: self.purchaseHandler ) } @@ -227,13 +248,27 @@ struct Template2View: TemplateViewType { @available(tvOS, unavailable) private extension Template2View { - 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.selectedPackage.localization } - var selectedLocalization: ProcessedLocalizedConfiguration { - return self.localization(for: self.selectedPackage) +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, *) +private extension PaywallViewMode { + + var shouldDisplayPackages: Bool { + switch self { + case .fullScreen: return true + case .card, .condensedCard: return false + } + } + + var shouldDisplayInlineOfferDetails: Bool { + switch self { + case .fullScreen: return false + case .card, .condensedCard: return true + } } } @@ -266,8 +301,13 @@ private extension Bundle { struct Template2View_Previews: PreviewProvider { static var previews: some View { - PreviewableTemplate(offering: TestData.offeringWithMultiPackagePaywall) { - Template2View($0) + ForEach(PaywallViewMode.allCases, id: \.self) { mode in + PreviewableTemplate( + offering: TestData.offeringWithMultiPackagePaywall, + mode: mode + ) { + Template2View($0) + } } } diff --git a/RevenueCatUI/Templates/Template3View.swift b/RevenueCatUI/Templates/Template3View.swift index d1cfd4f5f7..7865340433 100644 --- a/RevenueCatUI/Templates/Template3View.swift +++ b/RevenueCatUI/Templates/Template3View.swift @@ -26,30 +26,28 @@ struct Template3View: TemplateViewType { } var body: some View { - ZStack { - self.background - - self.content - } - } - - private var content: some View { VStack { - if let url = self.configuration.iconImageURL { - RemoteImage(url: url, aspectRatio: 1) - .frame(width: self.iconSize, height: self.iconSize) - .cornerRadius(8) + if self.configuration.mode.shouldDisplayIcon { + if let url = self.configuration.iconImageURL { + RemoteImage(url: url, aspectRatio: 1) + .frame(width: self.iconSize, height: self.iconSize) + .cornerRadius(8) + } } - Text(.init(self.localization.title)) - .font(self.font(for: .title)) - .foregroundStyle(self.configuration.colors.text1Color) - .multilineTextAlignment(.center) + if self.configuration.mode.shouldDisplayText { + Text(.init(self.localization.title)) + .font(self.font(for: .title)) + .foregroundStyle(self.configuration.colors.text1Color) + .multilineTextAlignment(.center) - Spacer() + Spacer() + } - self.features - .scrollableIfNecessary() + if self.configuration.mode.shouldDisplayFeatures { + self.features + .scrollableIfNecessary() + } Spacer() @@ -64,8 +62,7 @@ struct Template3View: TemplateViewType { .padding(.bottom) PurchaseButton( - package: self.configuration.packages.single.content, - localization: self.localization, + package: self.configuration.packages.single, configuration: self.configuration, introEligibility: self.introEligibility, purchaseHandler: self.purchaseHandler @@ -91,13 +88,6 @@ struct Template3View: TemplateViewType { .padding(.horizontal) } - private var background: some View { - Rectangle() - .foregroundStyle(self.configuration.colors.backgroundColor) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .edgesIgnoringSafeArea(.all) - } - private var introEligibility: IntroEligibilityStatus? { return self.introEligibilityViewModel.singleEligibility } diff --git a/RevenueCatUI/Templates/Template4View.swift b/RevenueCatUI/Templates/Template4View.swift index 11aa71b465..82b172b84e 100644 --- a/RevenueCatUI/Templates/Template4View.swift +++ b/RevenueCatUI/Templates/Template4View.swift @@ -12,15 +12,16 @@ import SwiftUI struct Template4View: TemplateViewType { let configuration: TemplateViewConfiguration - private var localization: [Package: ProcessedLocalizedConfiguration] @State - private var selectedPackage: Package + private var selectedPackage: TemplateViewConfiguration.Package @State private var packageContentHeight: CGFloat = 10 @State private var containerWidth: CGFloat = 600 + @State + private var displayingAllPlans: Bool @Environment(\.dynamicTypeSize) private var dynamicTypeSize @@ -31,10 +32,10 @@ struct Template4View: TemplateViewType { private var purchaseHandler: PurchaseHandler init(_ configuration: TemplateViewConfiguration) { - self._selectedPackage = .init(initialValue: configuration.packages.default.content) - self.configuration = configuration - self.localization = configuration.packages.localizationPerPackage() + + self._selectedPackage = .init(initialValue: configuration.packages.default) + self._displayingAllPlans = .init(initialValue: configuration.mode.displayAllPlansByDefault) } var body: some View { @@ -56,24 +57,26 @@ struct Template4View: TemplateViewType { @ViewBuilder var cardContent: some View { VStack(spacing: 20) { - Text(.init(self.selectedLocalization.title)) - .foregroundColor(self.configuration.colors.text1Color) - .font(self.font(for: .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 - } + if self.configuration.mode.shouldDisplayText { + Text(.init(self.selectedPackage.localization.title)) + .foregroundColor(self.configuration.colors.text1Color) + .font(self.font(for: .title).bold()) + .padding([.top, .bottom, .horizontal]) + .dynamicTypeSize(...Constants.maximumDynamicTypeSize) + } + + if self.configuration.mode.shouldDisplayPackages { + self.packagesScrollView + } else { + self.packagesScrollView + .padding(.vertical) + .hideCardContent(!self.displayingAllPlans, self.packageContentHeight) + } IntroEligibilityStateView( - textWithNoIntroOffer: self.selectedLocalization.offerDetails, - textWithIntroOffer: self.selectedLocalization.offerDetailsWithIntroOffer, - introEligibility: self.introEligibility[self.selectedPackage], + textWithNoIntroOffer: self.selectedPackage.localization.offerDetails, + textWithIntroOffer: self.selectedPackage.localization.offerDetailsWithIntroOffer, + introEligibility: self.introEligibility[self.selectedPackage.content], foregroundColor: self.configuration.colors.text1Color ) .font(self.font(for: .body).weight(.light)) @@ -84,7 +87,8 @@ struct Template4View: TemplateViewType { FooterView(configuration: self.configuration, bold: false, - purchaseHandler: self.purchaseHandler) + purchaseHandler: self.purchaseHandler, + displayingAllPlans: self.$displayingAllPlans) .frame(maxWidth: .infinity) } .animation(Constants.fastAnimation, value: self.selectedPackage) @@ -94,17 +98,26 @@ struct Template4View: TemplateViewType { } } + private var packagesScrollView: some View { + self.packages + .frame(height: self.packageContentHeight) + .scrollableIfNecessary(.horizontal) + .frame(maxWidth: .infinity) + .onSizeChange(.horizontal) { + self.containerWidth = $0 + } + } + 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 + let isSelected = self.selectedPackage.content === package.content Button { - self.selectedPackage = package.content + self.selectedPackage = package } label: { PackageButton(configuration: self.configuration, package: package, - localization: self.localization(for: package.content), selected: isSelected, packageWidth: self.packageWidth, desiredHeight: self.packageContentHeight) @@ -119,9 +132,8 @@ struct Template4View: TemplateViewType { private var subscribeButton: some View { PurchaseButton( package: self.selectedPackage, - localization: self.selectedLocalization, configuration: self.configuration, - introEligibility: self.introEligibility[self.selectedPackage], + introEligibility: self.introEligibility[self.selectedPackage.content], purchaseHandler: self.purchaseHandler ) } @@ -132,7 +144,6 @@ struct Template4View: TemplateViewType { 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) @@ -160,7 +171,7 @@ struct Template4View: TemplateViewType { return self.introEligibilityViewModel.allEligibility } - fileprivate static let cornerRadius: CGFloat = 16 + fileprivate static let cornerRadius = Constants.defaultCornerRadius fileprivate static let verticalPadding: CGFloat = 10 @ScaledMetric(relativeTo: .title2) @@ -190,7 +201,6 @@ private struct PackageButton: View { var configuration: TemplateViewConfiguration var package: TemplateViewConfiguration.Package - var localization: ProcessedLocalizedConfiguration var selected: Bool var packageWidth: CGFloat var desiredHeight: CGFloat? @@ -256,7 +266,7 @@ private struct PackageButton: View { @ViewBuilder private var offerName: some View { Group { - if let offerName = self.localization.offerName { + if let offerName = self.package.localization.offerName { let components = offerName.split(separator: " ", maxSplits: 2) if components.count == 2 { VStack { @@ -320,15 +330,13 @@ private struct PackageButton: View { // MARK: - Extensions @available(iOS 15.0, macOS 12.0, tvOS 15.0, *) -private extension Template4View { - - func localization(for package: Package) -> ProcessedLocalizedConfiguration { - // Because of how packages are constructed this is known to exist - return self.localization[package]! - } +private extension PaywallViewMode { - var selectedLocalization: ProcessedLocalizedConfiguration { - return self.localization(for: self.selectedPackage) + var shouldDisplayPackages: Bool { + switch self { + case .fullScreen: return true + case .card, .condensedCard: return false + } } } diff --git a/RevenueCatUI/Templates/TemplateViewType.swift b/RevenueCatUI/Templates/TemplateViewType.swift index c1b8822230..cda4d3b878 100644 --- a/RevenueCatUI/Templates/TemplateViewType.swift +++ b/RevenueCatUI/Templates/TemplateViewType.swift @@ -51,21 +51,36 @@ extension PaywallData { .task(id: offering) { await introEligibility.computeEligibility(for: configuration.packages) } - .background( - Rectangle() - .foregroundColor( - mode.shouldDisplayBackground - ? configuration.colors.backgroundColor - : .clear - ) - .edgesIgnoringSafeArea(.all) - ) + .background(self.background(configuration: configuration)) case let .failure(error): DebugErrorView(error, releaseBehavior: .emptyView) } } + @ViewBuilder + private func background( + configuration: TemplateViewConfiguration + ) -> some View { + let view = Rectangle() + .foregroundStyle(configuration.colors.backgroundColor) + .edgesIgnoringSafeArea(.all) + + switch configuration.mode { + case .fullScreen: + view + case .card, .condensedCard: + view + #if canImport(UIKit) + .roundedCorner( + Constants.defaultCornerRadius, + corners: [.topLeft, .topRight], + edgesIgnoringSafeArea: .all + ) + #endif + } + } + func configuration( for offering: Offering, mode: PaywallViewMode, @@ -105,14 +120,3 @@ extension PaywallData { } } - -private extension PaywallViewMode { - - var shouldDisplayBackground: Bool { - switch self { - case .fullScreen: return true - case .card, .banner: return false - } - } - -} diff --git a/RevenueCatUI/Views/FooterView.swift b/RevenueCatUI/Views/FooterView.swift index 7a002a7372..88649c7dd3 100644 --- a/RevenueCatUI/Views/FooterView.swift +++ b/RevenueCatUI/Views/FooterView.swift @@ -12,40 +12,56 @@ import SwiftUI struct FooterView: View { var configuration: PaywallData.Configuration + var mode: PaywallViewMode var fonts: PaywallFontProvider var color: Color var bold: Bool var purchaseHandler: PurchaseHandler + var displayingAllPlans: Binding? init( configuration: TemplateViewConfiguration, bold: Bool = true, - purchaseHandler: PurchaseHandler + purchaseHandler: PurchaseHandler, + displayingAllPlans: Binding? = nil ) { self.init( configuration: configuration.configuration, + mode: configuration.mode, fonts: configuration.fonts, color: configuration.colors.text1Color, - purchaseHandler: purchaseHandler + purchaseHandler: purchaseHandler, + displayingAllPlans: displayingAllPlans ) } fileprivate init( configuration: PaywallData.Configuration, + mode: PaywallViewMode, fonts: PaywallFontProvider, color: Color, bold: Bool = true, - purchaseHandler: PurchaseHandler + purchaseHandler: PurchaseHandler, + displayingAllPlans: Binding? ) { self.configuration = configuration + self.mode = mode self.fonts = fonts self.color = color self.bold = bold self.purchaseHandler = purchaseHandler + self.displayingAllPlans = displayingAllPlans } var body: some View { HStack { + if self.mode.displayAllPlansButton, let binding = self.displayingAllPlans { + Self.allPlansButton(binding) + + self.separator + .hidden(if: !self.configuration.displayRestorePurchases && !self.hasTOS && !self.hasPrivacy) + } + if self.configuration.displayRestorePurchases { RestorePurchasesButton(purchaseHandler: self.purchaseHandler) @@ -72,11 +88,22 @@ struct FooterView: View { } .foregroundColor(self.color) .font(self.fonts.font(for: Self.font).weight(self.fontWeight)) + .frame(maxWidth: .infinity) .padding(.horizontal) .padding(.bottom, 5) .dynamicTypeSize(...Constants.maximumDynamicTypeSize) } + private static func allPlansButton(_ binding: Binding) -> some View { + Button { + withAnimation(Constants.displayAllPlansAnimation) { + binding.wrappedValue.toggle() + } + } label: { + Text("All plans", bundle: .module) + } + } + private var separator: some View { SeparatorView(bold: self.bold) } @@ -178,6 +205,8 @@ private struct LinkButton: View { } +// MARK: - Previews + #if DEBUG && canImport(SwiftUI) && canImport(UIKit) @available(iOS 16.0, macOS 13.0, tvOS 16.0, *) @@ -240,10 +269,12 @@ struct Footer_Previews: PreviewProvider { termsOfServiceURL: termsOfServiceURL, privacyURL: privacyURL ), + mode: .fullScreen, fonts: DefaultPaywallFontProvider(), color: TestData.colors.text1Color, bold: bold, - purchaseHandler: PreviewHelpers.purchaseHandler + purchaseHandler: PreviewHelpers.purchaseHandler, + displayingAllPlans: .constant(false) ) } diff --git a/RevenueCatUI/Views/LoadingPaywallView.swift b/RevenueCatUI/Views/LoadingPaywallView.swift index 2157d4066f..11fbd9135f 100644 --- a/RevenueCatUI/Views/LoadingPaywallView.swift +++ b/RevenueCatUI/Views/LoadingPaywallView.swift @@ -15,6 +15,8 @@ import SwiftUI @MainActor struct LoadingPaywallView: View { + var mode: PaywallViewMode + var body: some View { LoadedOfferingPaywallView( offering: .init( @@ -25,7 +27,7 @@ struct LoadingPaywallView: View { availablePackages: Self.packages ), paywall: Self.defaultPaywall, - mode: .fullScreen, + mode: self.mode, fonts: DefaultPaywallFontProvider(), introEligibility: Self.introEligibility, purchaseHandler: Self.purchaseHandler @@ -126,7 +128,10 @@ private extension LoadingPaywallView { struct LoadingPaywallView_Previews: PreviewProvider { static var previews: some View { - LoadingPaywallView() + ForEach(PaywallViewMode.allCases, id: \.self) { mode in + LoadingPaywallView(mode: mode) + .previewDisplayName("\(mode)") + } } } diff --git a/RevenueCatUI/Views/PurchaseButton.swift b/RevenueCatUI/Views/PurchaseButton.swift index cd66a4a0de..6ba74f1dee 100644 --- a/RevenueCatUI/Views/PurchaseButton.swift +++ b/RevenueCatUI/Views/PurchaseButton.swift @@ -12,10 +12,9 @@ import SwiftUI @available(tvOS, unavailable) struct PurchaseButton: View { - let package: Package + let package: TemplateViewConfiguration.Package let colors: PaywallData.Configuration.Colors let fonts: PaywallFontProvider - let localization: ProcessedLocalizedConfiguration let introEligibility: IntroEligibilityStatus? let mode: PaywallViewMode @@ -23,8 +22,7 @@ struct PurchaseButton: View { var purchaseHandler: PurchaseHandler init( - package: Package, - localization: ProcessedLocalizedConfiguration, + package: TemplateViewConfiguration.Package, configuration: TemplateViewConfiguration, introEligibility: IntroEligibilityStatus?, purchaseHandler: PurchaseHandler @@ -33,7 +31,6 @@ struct PurchaseButton: View { package: package, colors: configuration.colors, fonts: configuration.fonts, - localization: localization, introEligibility: introEligibility, mode: configuration.mode, purchaseHandler: purchaseHandler @@ -41,10 +38,9 @@ struct PurchaseButton: View { } init( - package: Package, + package: TemplateViewConfiguration.Package, colors: PaywallData.Configuration.Colors, fonts: PaywallFontProvider, - localization: ProcessedLocalizedConfiguration, introEligibility: IntroEligibilityStatus?, mode: PaywallViewMode, purchaseHandler: PurchaseHandler @@ -52,7 +48,6 @@ struct PurchaseButton: View { self.package = package self.colors = colors self.fonts = fonts - self.localization = localization self.introEligibility = introEligibility self.mode = mode self.purchaseHandler = purchaseHandler @@ -69,7 +64,7 @@ struct PurchaseButton: View { AsyncButton { guard !self.purchaseHandler.actionInProgress else { return } - let cancelled = try await self.purchaseHandler.purchase(package: self.package, + let cancelled = try await self.purchaseHandler.purchase(package: self.package.content, with: self.mode).userCancelled if !cancelled, case .fullScreen = self.mode { @@ -77,8 +72,8 @@ struct PurchaseButton: View { } } label: { IntroEligibilityStateView( - textWithNoIntroOffer: self.localization.callToAction, - textWithIntroOffer: self.localization.callToActionWithIntroOffer, + textWithNoIntroOffer: self.package.localization.callToAction, + textWithIntroOffer: self.package.localization.callToActionWithIntroOffer, introEligibility: self.introEligibility, foregroundColor: self.colors.callToActionForegroundColor ) @@ -104,37 +99,31 @@ private extension PaywallViewMode { var buttonFont: Font.TextStyle { switch self { - case .fullScreen, .card: return .title3 - case .banner: return .footnote + case .fullScreen, .card, .condensedCard: return .title3 } } var fullWidthButton: Bool { switch self { - case .fullScreen, .card: return true - case .banner: return false + case .fullScreen, .card, .condensedCard: return true } } @available(tvOS, unavailable) var buttonSize: ControlSize { switch self { - case .fullScreen: return .large - case .card: return .regular - case .banner: return .small + case .fullScreen, .card, .condensedCard: return .large } } var buttonBorderShape: ButtonBorderShape { switch self { - case .fullScreen: + case .fullScreen, .card, .condensedCard: #if os(macOS) || os(tvOS) return .roundedRectangle #else return .capsule #endif - case .card, .banner: - return .roundedRectangle } } @@ -161,31 +150,30 @@ struct PurchaseButton_Previews: PreviewProvider { package: Self.package, colors: TestData.colors, fonts: DefaultPaywallFontProvider(), - localization: TestData.localization1.processVariables(with: Self.package, locale: .current), introEligibility: self.eligibility, mode: self.mode, purchaseHandler: PreviewHelpers.purchaseHandler ) .task { - self.eligibility = await PreviewHelpers.introEligibilityChecker.eligibility(for: Self.package) + self.eligibility = await PreviewHelpers.introEligibilityChecker.eligibility(for: Self.package.content) } } - private static let package: Package = TestData.packageWithIntroOffer - + private static let package: TemplateViewConfiguration.Package = .init( + content: TestData.packageWithIntroOffer, + localization: TestData.localization1.processVariables(with: TestData.packageWithIntroOffer, + locale: .current), + discountRelativeToMostExpensivePerMonth: nil + ) } static var previews: some View { - ForEach(Self.modes, id: \.self) { mode in + ForEach(PaywallViewMode.allCases, id: \.self) { mode in Preview(mode: mode) .previewLayout(.sizeThatFits) } } - private static let modes: [PaywallViewMode] = [ - .fullScreen - ] - } #endif diff --git a/RevenueCatUI/Views/TemplateBackgroundImageView.swift b/RevenueCatUI/Views/TemplateBackgroundImageView.swift index 5bf9cf3d68..da39e91272 100644 --- a/RevenueCatUI/Views/TemplateBackgroundImageView.swift +++ b/RevenueCatUI/Views/TemplateBackgroundImageView.swift @@ -14,7 +14,8 @@ struct TemplateBackgroundImageView: View { var configuration: TemplateViewConfiguration var body: some View { - if let url = self.configuration.backgroundImageURL { + if self.configuration.mode.shouldDisplayBackground, + let url = self.configuration.backgroundImageURL { self.image(url) .unredacted() .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -34,3 +35,14 @@ struct TemplateBackgroundImageView: View { } } + +private extension PaywallViewMode { + + var shouldDisplayBackground: Bool { + switch self { + case .fullScreen: return true + case .card, .condensedCard: return false + } + } + +} diff --git a/Sources/Paywalls/PaywallViewMode.swift b/Sources/Paywalls/PaywallViewMode.swift index bbdd2c82a8..2ac9b9b627 100644 --- a/Sources/Paywalls/PaywallViewMode.swift +++ b/Sources/Paywalls/PaywallViewMode.swift @@ -19,13 +19,13 @@ public enum PaywallViewMode { /// Paywall is displayed full-screen, with as much information as available. case fullScreen - /// Paywall is displayed with a square aspect ratio. It can be embedded inside any other SwiftUI view. - @available(*, unavailable, message: "Other modes coming soon.") + /// Paywall can be displayed as an overlay on top of your own content. + /// Multi-package templates will display the package selection. case card - /// Paywall is displayed in a condensed format. It can be embedded inside any other SwiftUI view. - @available(*, unavailable, message: "Other modes coming soon.") - case banner + /// Paywall can be displayed as an overlay on top of your own content. + /// Multi-package templates will include a button to make the package selection visible. + case condensedCard /// The default ``PaywallViewMode``: ``PaywallViewMode/fullScreen``. public static let `default`: Self = .fullScreen @@ -38,7 +38,7 @@ extension PaywallViewMode { public var isFullScreen: Bool { switch self { case .fullScreen: return true - case .card, .banner: return false + case .card, .condensedCard: return false } } @@ -50,7 +50,7 @@ extension PaywallViewMode { switch self { case .fullScreen: return "full_screen" case .card: return "card" - case .banner: return "banner" + case .condensedCard: return "condensed_card" } } @@ -58,18 +58,7 @@ extension PaywallViewMode { // MARK: - Extensions -extension PaywallViewMode: CaseIterable { - - // Note: this manual implementation can be deleted when all modes are available. - // swiftlint:disable:next missing_docs - public static var allCases: [PaywallViewMode] { - return [ - .fullScreen - ] - } - -} - +extension PaywallViewMode: CaseIterable {} extension PaywallViewMode: Sendable {} extension PaywallViewMode: Hashable {} diff --git a/Tests/APITesters/SwiftAPITester/SwiftAPITester/PaywallAPI.swift b/Tests/APITesters/SwiftAPITester/SwiftAPITester/PaywallAPI.swift index 7167421859..d88f12ac5c 100644 --- a/Tests/APITesters/SwiftAPITester/SwiftAPITester/PaywallAPI.swift +++ b/Tests/APITesters/SwiftAPITester/SwiftAPITester/PaywallAPI.swift @@ -165,7 +165,7 @@ func checkPaywallViewMode(_ mode: PaywallViewMode) { break case .card: break - case .banner: + case .condensedCard: break @unknown default: break diff --git a/Tests/RevenueCatUITests/BaseSnapshotTest.swift b/Tests/RevenueCatUITests/BaseSnapshotTest.swift index 54039091ef..194123f1fd 100644 --- a/Tests/RevenueCatUITests/BaseSnapshotTest.swift +++ b/Tests/RevenueCatUITests/BaseSnapshotTest.swift @@ -37,12 +37,7 @@ extension BaseSnapshotTest { static let fonts: PaywallFontProvider = CustomPaywallFontProvider(fontName: "Papyrus") static let fullScreenSize: CGSize = .init(width: 460, height: 950) - - // Disabled until we bring modes back. - /* static let cardSize: CGSize = .init(width: 460, height: 460) - static let bannerSize: CGSize = .init(width: 380, height: 70) - */ } diff --git a/Tests/RevenueCatUITests/Data/TemplateViewConfigurationTests.swift b/Tests/RevenueCatUITests/Data/TemplateViewConfigurationTests.swift index 9f6eb86b72..8392241aa6 100644 --- a/Tests/RevenueCatUITests/Data/TemplateViewConfigurationTests.swift +++ b/Tests/RevenueCatUITests/Data/TemplateViewConfigurationTests.swift @@ -135,70 +135,6 @@ class TemplateViewConfigurationCreationTests: BaseTemplateViewConfigurationTests } -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -class TemplateViewConfigurationHelpersTests: BaseTemplateViewConfigurationTests { - - func testLocalizationPerPackageWithOnePackage() throws { - let configuration = try Config.create( - with: [TestData.monthlyPackage], - filter: [PackageType.monthly.identifier], - default: nil, - localization: Self.localization, - setting: .single - ) - - expect(configuration.localizationPerPackage()) == [ - TestData.monthlyPackage: Self.localization.processVariables(with: TestData.monthlyPackage) - ] - } - - func testLocalizationPerPackageWithMultiplePackages() throws { - let configuration = try Config.create( - with: [TestData.monthlyPackage, - TestData.annualPackage, - TestData.weeklyPackage, - TestData.lifetimePackage], - filter: [PackageType.annual.identifier, - PackageType.monthly.identifier, - PackageType.lifetime.identifier], - default: PackageType.monthly.identifier, - localization: Self.localization, - setting: .multiple - ) - - expect(configuration.localizationPerPackage()) == [ - TestData.monthlyPackage: Self.localization.processVariables(with: TestData.monthlyPackage), - TestData.annualPackage: Self.localization.processVariables(with: TestData.annualPackage), - TestData.lifetimePackage: Self.localization.processVariables(with: TestData.lifetimePackage) - ] - } - - // Frontend ensures that having duplicates isn't possible, but this ensures proper behavior. - func testLocalizationPerPackageWithDuplicatePackages() throws { - let configuration = try Config.create( - with: [TestData.monthlyPackage, - TestData.annualPackage, - TestData.weeklyPackage, - TestData.lifetimePackage, - Self.consumable], - filter: [PackageType.monthly.identifier, - PackageType.monthly.identifier, - PackageType.annual.identifier, - Self.consumable.identifier], - default: PackageType.monthly.identifier, - localization: Self.localization, - setting: .multiple - ) - - expect(configuration.localizationPerPackage()) == [ - TestData.monthlyPackage: Self.localization.processVariables(with: TestData.monthlyPackage), - TestData.annualPackage: Self.localization.processVariables(with: TestData.annualPackage), - Self.consumable: Self.localization.processVariables(with: Self.consumable) - ] - } - -} - @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) class TemplateViewConfigurationFilteringTests: BaseTemplateViewConfigurationTests { diff --git a/Tests/RevenueCatUITests/Templates/OtherPaywallViewTests.swift b/Tests/RevenueCatUITests/Templates/OtherPaywallViewTests.swift index 02456710e1..9708005993 100644 --- a/Tests/RevenueCatUITests/Templates/OtherPaywallViewTests.swift +++ b/Tests/RevenueCatUITests/Templates/OtherPaywallViewTests.swift @@ -33,8 +33,18 @@ class OtherPaywallViewTests: BaseSnapshotTest { } func testLoadingPaywallView() { - let view = LoadingPaywallView() - view.snapshot(size: Self.fullScreenSize) + LoadingPaywallView(mode: .fullScreen) + .snapshot(size: Self.fullScreenSize) + } + + func testLoadingCardPaywallView() { + LoadingPaywallView(mode: .card) + .snapshot(size: Self.cardSize) + } + + func testLoadingCondensedCardPaywallView() { + LoadingPaywallView(mode: .condensedCard) + .snapshot(size: Self.cardSize) } private static let offeringWithNoPaywall = Offering( diff --git a/Tests/RevenueCatUITests/Templates/Template1ViewTests.swift b/Tests/RevenueCatUITests/Templates/Template1ViewTests.swift index ff991f5807..48df5c63c9 100644 --- a/Tests/RevenueCatUITests/Templates/Template1ViewTests.swift +++ b/Tests/RevenueCatUITests/Templates/Template1ViewTests.swift @@ -24,28 +24,21 @@ class Template1ViewTests: BaseSnapshotTest { .snapshot(size: Self.fullScreenSize) } - // Disabled until we bring modes back. - /* func testCardPaywall() { - let view = PaywallView(offering: Self.offeringWithNoIntroOffer, - mode: .card, - introEligibility: Self.eligibleChecker, - purchaseHandler: Self.purchaseHandler) - .background(.white) // Non-fullscreen views have no background - - view.snapshot(size: Self.cardSize) + PaywallView(offering: Self.offeringWithNoIntroOffer, + mode: .card, + introEligibility: Self.eligibleChecker, + purchaseHandler: Self.purchaseHandler) + .snapshot(size: Self.cardSize) } - func testBannerPaywall() { - let view = PaywallView(offering: Self.offeringWithNoIntroOffer, - mode: .banner, - introEligibility: Self.eligibleChecker, - purchaseHandler: Self.purchaseHandler) - .background(.white) // Non-fullscreen views have no background - - view.snapshot(size: Self.bannerSize) + func testCondensedCardPaywall() { + PaywallView(offering: Self.offeringWithNoIntroOffer, + mode: .condensedCard, + introEligibility: Self.eligibleChecker, + purchaseHandler: Self.purchaseHandler) + .snapshot(size: Self.cardSize) } - */ func testSamplePaywallWithIntroOffer() { let view = PaywallView(offering: Self.offeringWithIntroOffer, diff --git a/Tests/RevenueCatUITests/Templates/Template2ViewTests.swift b/Tests/RevenueCatUITests/Templates/Template2ViewTests.swift index 6ab838d098..dbbca3852a 100644 --- a/Tests/RevenueCatUITests/Templates/Template2ViewTests.swift +++ b/Tests/RevenueCatUITests/Templates/Template2ViewTests.swift @@ -23,6 +23,22 @@ class Template2ViewTests: BaseSnapshotTest { .snapshot(size: Self.fullScreenSize) } + func testCardPaywall() { + PaywallView(offering: Self.offering.withLocalImages, + mode: .card, + introEligibility: Self.eligibleChecker, + purchaseHandler: Self.purchaseHandler) + .snapshot(size: Self.cardSize) + } + + func testCondensedCardPaywall() { + PaywallView(offering: Self.offering.withLocalImages, + mode: .condensedCard, + introEligibility: Self.eligibleChecker, + purchaseHandler: Self.purchaseHandler) + .snapshot(size: Self.cardSize) + } + func testPurchasingState() { let handler = Self.purchaseHandler.with(delay: 120) diff --git a/Tests/RevenueCatUITests/Templates/Template3ViewTests.swift b/Tests/RevenueCatUITests/Templates/Template3ViewTests.swift index e0bda6b680..830306f3ee 100644 --- a/Tests/RevenueCatUITests/Templates/Template3ViewTests.swift +++ b/Tests/RevenueCatUITests/Templates/Template3ViewTests.swift @@ -23,6 +23,22 @@ class Template3ViewTests: BaseSnapshotTest { .snapshot(size: Self.fullScreenSize) } + func testCardPaywall() { + PaywallView(offering: Self.offering.withLocalImages, + mode: .card, + introEligibility: Self.eligibleChecker, + purchaseHandler: Self.purchaseHandler) + .snapshot(size: Self.cardSize) + } + + func testCondensedCardPaywall() { + PaywallView(offering: Self.offering.withLocalImages, + mode: .condensedCard, + introEligibility: Self.eligibleChecker, + purchaseHandler: Self.purchaseHandler) + .snapshot(size: Self.cardSize) + } + private static let offering = TestData.offeringWithSinglePackageFeaturesPaywall } diff --git a/Tests/RevenueCatUITests/Templates/Template4ViewTests.swift b/Tests/RevenueCatUITests/Templates/Template4ViewTests.swift index 0d1c9bf00a..a02b45a454 100644 --- a/Tests/RevenueCatUITests/Templates/Template4ViewTests.swift +++ b/Tests/RevenueCatUITests/Templates/Template4ViewTests.swift @@ -39,6 +39,22 @@ class Template4ViewTests: BaseSnapshotTest { .snapshot(size: Self.fullScreenSize) } + func testCardPaywall() { + PaywallView(offering: Self.offering.withLocalImages, + mode: .card, + introEligibility: Self.eligibleChecker, + purchaseHandler: Self.purchaseHandler) + .snapshot(size: Self.cardSize) + } + + func testCondensedCardPaywall() { + PaywallView(offering: Self.offering.withLocalImages, + mode: .condensedCard, + introEligibility: Self.eligibleChecker, + purchaseHandler: Self.purchaseHandler) + .snapshot(size: Self.cardSize) + } + private static let offering = TestData.offeringWithMultiPackageHorizontalPaywall } diff --git a/Tests/TestingApps/SimpleApp/SimpleApp.xcodeproj/project.pbxproj b/Tests/TestingApps/SimpleApp/SimpleApp.xcodeproj/project.pbxproj index 4433d38fcb..707fd82e93 100644 --- a/Tests/TestingApps/SimpleApp/SimpleApp.xcodeproj/project.pbxproj +++ b/Tests/TestingApps/SimpleApp/SimpleApp.xcodeproj/project.pbxproj @@ -7,10 +7,11 @@ objects = { /* Begin PBXBuildFile section */ + 4F102E272A840ECC0059EED6 /* CustomPaywall.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F102E262A840ECC0059EED6 /* CustomPaywall.swift */; }; 4F217A102A6DB6FB000B092D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 4FC046BF2A572E3700A28BCF /* Assets.xcassets */; }; - 4F4557E22A6FFE6A00160521 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 4F4557E42A6FFE6A00160521 /* Localizable.strings */; }; 4F34FF632A60AD9A00AADF11 /* AppContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F34FF622A60AD9A00AADF11 /* AppContentView.swift */; }; 4F34FF652A60ADBD00AADF11 /* Configuration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F34FF642A60ADBD00AADF11 /* Configuration.swift */; }; + 4F4557E22A6FFE6A00160521 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 4F4557E42A6FFE6A00160521 /* Localizable.strings */; }; 4F4EE7CD2A572F5400D7EAE1 /* SimpleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC046BC2A572E3700A28BCF /* SimpleApp.swift */; }; 4F4EE7CF2A572F5D00D7EAE1 /* DebugView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC046C32A572E3700A28BCF /* DebugView.swift */; }; 4F4EE7D22A5731E800D7EAE1 /* RevenueCat in Frameworks */ = {isa = PBXBuildFile; productRef = 4F4EE7D12A5731E800D7EAE1 /* RevenueCat */; }; @@ -36,10 +37,11 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 4F4557E32A6FFE6A00160521 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; - 4F4557E52A6FFE6D00160521 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = ""; }; + 4F102E262A840ECC0059EED6 /* CustomPaywall.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomPaywall.swift; sourceTree = ""; }; 4F34FF622A60AD9A00AADF11 /* AppContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppContentView.swift; sourceTree = ""; }; 4F34FF642A60ADBD00AADF11 /* Configuration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Configuration.swift; sourceTree = ""; }; + 4F4557E32A6FFE6A00160521 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; + 4F4557E52A6FFE6D00160521 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = ""; }; 4F4EE7D02A5731CF00D7EAE1 /* purchases-ios */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = "purchases-ios"; path = ../../..; sourceTree = ""; }; 4F6BED9A2A26A64200CD9322 /* SimpleApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SimpleApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; 4F6E5A4F2A660DD500C573C7 /* SampleChart.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SampleChart.swift; sourceTree = ""; }; @@ -78,6 +80,7 @@ 4FC6F8B12A7403D3002139B2 /* OfferingsList.swift */, 4F6E5A4F2A660DD500C573C7 /* SampleChart.swift */, 4FDF111F2A7270F3004F3680 /* SamplePaywallsList.swift */, + 4F102E262A840ECC0059EED6 /* CustomPaywall.swift */, ); path = Views; sourceTree = ""; @@ -223,6 +226,7 @@ 4FDF11222A72714C004F3680 /* SamplePaywalls.swift in Sources */, 4F34FF652A60ADBD00AADF11 /* Configuration.swift in Sources */, 4FC6F8B22A7403D3002139B2 /* OfferingsList.swift in Sources */, + 4F102E272A840ECC0059EED6 /* CustomPaywall.swift in Sources */, 4FDF11202A7270F3004F3680 /* SamplePaywallsList.swift in Sources */, 4F34FF632A60AD9A00AADF11 /* AppContentView.swift in Sources */, ); diff --git a/Tests/TestingApps/SimpleApp/SimpleApp/SamplePaywalls.swift b/Tests/TestingApps/SimpleApp/SimpleApp/SamplePaywalls.swift index aa1022820d..483aeecd35 100644 --- a/Tests/TestingApps/SimpleApp/SimpleApp/SamplePaywalls.swift +++ b/Tests/TestingApps/SimpleApp/SimpleApp/SamplePaywalls.swift @@ -243,7 +243,7 @@ private extension SamplePaywallLoader { localization: .init( title: "Call to action for better conversion.", subtitle: "Lorem ipsum is simply dummy text of the printing and typesetting industry.", - callToAction: "Subscribe for {{ sub_price_per_month }}/mo", + callToAction: "Subscribe for {{ price_per_period }}", offerDetails: "{{ total_price_and_per_month }}", offerDetailsWithIntroOffer: "{{ total_price_and_per_month }} after {{ sub_offer_duration }} trial", offerName: "{{ sub_period }}" diff --git a/Tests/TestingApps/SimpleApp/SimpleApp/SimpleApp.swift b/Tests/TestingApps/SimpleApp/SimpleApp/SimpleApp.swift index 7bde59ffb7..c835c4e25b 100644 --- a/Tests/TestingApps/SimpleApp/SimpleApp/SimpleApp.swift +++ b/Tests/TestingApps/SimpleApp/SimpleApp/SimpleApp.swift @@ -30,15 +30,6 @@ struct SimpleApp: App { NavigationView { AppContentView() } - #if DEBUG - .overlay { - if #available(iOS 16.0, macOS 13.0, *) { - DebugView() - .frame(maxHeight: .infinity, alignment: .bottom) - .offset(y: -50) - } - } - #endif } } diff --git a/Tests/TestingApps/SimpleApp/SimpleApp/Views/AppContentView.swift b/Tests/TestingApps/SimpleApp/SimpleApp/Views/AppContentView.swift index 7c0148575d..722cd5fa5c 100644 --- a/Tests/TestingApps/SimpleApp/SimpleApp/Views/AppContentView.swift +++ b/Tests/TestingApps/SimpleApp/SimpleApp/Views/AppContentView.swift @@ -35,9 +35,12 @@ struct AppContentView: View { var body: some View { TabView { - ZStack { - self.background - self.content + NavigationView { + ZStack { + self.background + self.content + } + .navigationTitle("Paywall Tester") } .tabItem { Label("App", systemImage: "iphone") @@ -99,6 +102,14 @@ struct AppContentView: View { self.customerInfo = info } } + #if DEBUG + .overlay { + if #available(iOS 16.0, macOS 13.0, *) { + DebugView() + .frame(maxHeight: .infinity, alignment: .bottom) + } + } + #endif } } diff --git a/Tests/TestingApps/SimpleApp/SimpleApp/Views/CustomPaywall.swift b/Tests/TestingApps/SimpleApp/SimpleApp/Views/CustomPaywall.swift new file mode 100644 index 0000000000..a7aee80911 --- /dev/null +++ b/Tests/TestingApps/SimpleApp/SimpleApp/Views/CustomPaywall.swift @@ -0,0 +1,76 @@ +// +// CustomPaywall.swift +// SimpleApp +// +// Created by Nacho Soto on 8/9/23. +// + +import RevenueCat +@testable import RevenueCatUI +import SwiftUI + +struct CustomPaywall: View { + + var offering: Offering? + var mode: PaywallViewMode + var introEligibility: TrialOrIntroEligibilityChecker? + var purchaseHandler: PurchaseHandler? + + var body: some View { + NavigationView { + self.content + .overlay(alignment: .bottom) { + PaywallView(offering: self.offering, + mode: self.mode, + fonts: DefaultPaywallFontProvider(), + introEligibility: self.introEligibility ?? .default(), + purchaseHandler: self.purchaseHandler ?? .default() + ) + } + .navigationTitle("Custom paywall") + } + } + + private var content: some View { + VStack { + BarChartView(data: (0..<10).map { _ in Double.random(in: 0..<100)}) + .frame(maxWidth: .infinity) + BarChartView(data: (0..<10).map { _ in Double.random(in: 0..<100)}) + .frame(maxWidth: .infinity) + BarChartView(data: (0..<10).map { _ in Double.random(in: 0..<100)}) + .frame(maxWidth: .infinity) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background( + Color(white: 0.8) + .edgesIgnoringSafeArea(.all) + ) + } + +} + + +#if DEBUG + +struct CustomPaywall_Previews: PreviewProvider { + + static var previews: some View { + ForEach(Self.modes, id: \.self) { mode in + CustomPaywall( + offering: TestData.offeringWithMultiPackageHorizontalPaywall, + mode: mode, + introEligibility: .producing(eligibility: .eligible), + purchaseHandler: .mock() + ) + .previewDisplayName("\(mode)") + } + } + + private static let modes: [PaywallViewMode] = [ + .card, + .condensedCard + ] + +} + +#endif diff --git a/Tests/TestingApps/SimpleApp/SimpleApp/Views/OfferingsList.swift b/Tests/TestingApps/SimpleApp/SimpleApp/Views/OfferingsList.swift index 0ff9c16b10..b284a23aad 100644 --- a/Tests/TestingApps/SimpleApp/SimpleApp/Views/OfferingsList.swift +++ b/Tests/TestingApps/SimpleApp/SimpleApp/Views/OfferingsList.swift @@ -18,8 +18,10 @@ struct OfferingsList: View { private var selectedOffering: Offering? var body: some View { - self.content - .navigationTitle("Offerings") + NavigationView { + self.content + .navigationTitle("Live Paywalls") + } .task { do { self.offerings = .success( diff --git a/Tests/TestingApps/SimpleApp/SimpleApp/Views/SamplePaywallsList.swift b/Tests/TestingApps/SimpleApp/SimpleApp/Views/SamplePaywallsList.swift index 346d2151c9..759def0754 100644 --- a/Tests/TestingApps/SimpleApp/SimpleApp/Views/SamplePaywallsList.swift +++ b/Tests/TestingApps/SimpleApp/SimpleApp/Views/SamplePaywallsList.swift @@ -17,13 +17,25 @@ struct SamplePaywallsList: View { private var display: Display? var body: some View { - self.list(with: Self.loader) + NavigationView { + self.list(with: Self.loader) + .navigationTitle("Test Paywalls") + } .sheet(item: self.$display) { display in switch display { - case let .template(template): - PaywallView(offering: Self.loader.offering(for: template), - introEligibility: Self.introEligibility, - purchaseHandler: .default()) + case let .template(template, mode): + switch mode { + case .fullScreen: + PaywallView(offering: Self.loader.offering(for: template), + introEligibility: Self.introEligibility, + purchaseHandler: .default()) + + case .card, .condensedCard: + CustomPaywall(offering: Self.loader.offering(for: template), + mode: mode, + introEligibility: Self.introEligibility, + purchaseHandler: .default()) + } case let .customFont(template): PaywallView(offering: Self.loader.offering(for: template), @@ -31,6 +43,9 @@ struct SamplePaywallsList: View { introEligibility: Self.introEligibility, purchaseHandler: .default()) + case let .customPaywall(mode): + CustomPaywall(mode: mode) + case .defaultTemplate: PaywallView(offering: Self.loader.offeringWithDefaultPaywall(), introEligibility: Self.introEligibility, @@ -42,31 +57,44 @@ struct SamplePaywallsList: View { private func list(with loader: SamplePaywallLoader) -> some View { List { - Section("Templates") { - ForEach(PaywallTemplate.allCases, id: \.rawValue) { template in - Button { - self.display = .template(template) - } label: { - TemplateLabel(name: template.name) + ForEach(PaywallTemplate.allCases, id: \.rawValue) { template in + Section(template.name) { + ForEach(PaywallViewMode.allCases, id: \.self) { mode in + Button { + self.display = .template(template, mode) + } label: { + TemplateLabel(name: mode.name, icon: mode.icon) + } } - } - } - Section("Custom Font") { - ForEach(PaywallTemplate.allCases, id: \.rawValue) { template in Button { self.display = .customFont(template) } label: { - TemplateLabel(name: template.name) + TemplateLabel(name: "Custom font", icon: "textformat") + .italic() } } } Section("Other") { + Button { + self.display = .customPaywall(.card) + } label: { + TemplateLabel(name: "Custom + card", + icon: PaywallViewMode.card.icon) + } + + Button { + self.display = .customPaywall(.condensedCard) + } label: { + TemplateLabel(name: "Custom + condensed card", + icon: PaywallViewMode.condensedCard.icon) + } + Button { self.display = .defaultTemplate } label: { - TemplateLabel(name: "Default template") + TemplateLabel(name: "Default template", icon: "exclamationmark.triangle") } } } @@ -94,9 +122,10 @@ struct SamplePaywallsList: View { private struct TemplateLabel: View { var name: String + var icon: String var body: some View { - Text(self.name) + Label(self.name, systemImage: self.icon) .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) } @@ -109,8 +138,9 @@ private extension SamplePaywallsList { enum Display { - case template(PaywallTemplate) + case template(PaywallTemplate, PaywallViewMode) case customFont(PaywallTemplate) + case customPaywall(PaywallViewMode) case defaultTemplate } @@ -121,12 +151,15 @@ extension SamplePaywallsList.Display: Identifiable { public var id: String { switch self { - case let .template(template): - return "template-" + template.rawValue + case let .template(template, mode): + return "template-\(template.rawValue)-\(mode)" case let .customFont(template): return "custom-font-" + template.rawValue + case .customPaywall: + return "custom-paywall" + case .defaultTemplate: return "default" } @@ -139,13 +172,36 @@ extension PaywallTemplate { var name: String { switch self { case .template1: - return "Minimalist" + return "#1: Minimalist" case .template2: - return "Bold packages" + return "#2: Bold packages" case .template3: - return "Feature list" + return "#3: Feature list" case .template4: - return "Horizontal packages" + return "#4: Horizontal packages" + } + } + +} + +private extension PaywallViewMode { + + var icon: String { + switch self { + case .fullScreen: return "iphone" + case .card: return "lanyardcard" + case .condensedCard: return "ruler" + } + } + + var name: String { + switch self { + case .fullScreen: + return "Fullscreen" + case .card: + return "Card" + case .condensedCard: + return "Condensed Card" } }