diff --git a/RevenueCatUI/Data/TestData.swift b/RevenueCatUI/Data/TestData.swift index 3ee454741c..7c8c643a1f 100644 --- a/RevenueCatUI/Data/TestData.swift +++ b/RevenueCatUI/Data/TestData.swift @@ -8,7 +8,7 @@ import Foundation import RevenueCat -// swiftlint:disable type_body_length +// swiftlint:disable type_body_length file_length #if DEBUG @@ -192,6 +192,52 @@ internal enum TestData { Self.annualPackage] ) + static let offeringWithSinglePackageFeaturesPaywall = Offering( + identifier: Self.offeringIdentifier, + serverDescription: "Offering", + metadata: [:], + paywall: .init( + template: .onePackageWithFeatures, + config: .init( + packages: [.annual], + images: Self.images, + colors: .init( + light: .init( + background: "#272727", + foreground: "#FFFFFF", + callToActionBackground: "#FFFFFF", + callToActionForeground: "#000000", + accent1: "#F4E971", + accent2: "#B7B7B7" + ) + ), + termsOfServiceURL: URL(string: "https://revenuecat.com/tos")! + ), + localization: .init( + title: "How your free trial works", + callToAction: "Start", + callToActionWithIntroOffer: "Start your {{ intro_duration }} free", + offerDetails: "Only {{ price }} per {{ period }}", + offerDetailsWithIntroOffer: "First {{ intro_duration }} free,\nthen {{ total_price_and_per_month }}", + features: [ + .init(title: "Today", + content: "Full access to 1000+ workouts plus free meal plan worth {{ price }}.", + iconID: "tick"), + .init(title: "Day 7", + content: "Get a reminder about when your trial is about to end.", + iconID: "notifications"), + .init(title: "Day 14", + content: "You'll automatically get subscribed. " + + "Cancel anytime before if you didn't love our app.", + iconID: "attachment") + ]), + assetBaseURL: Self.paywallAssetBaseURL + ), + availablePackages: [Self.weeklyPackage, + Self.monthlyPackage, + Self.annualPackage] + ) + static let lightColors: PaywallData.Configuration.Colors = .init( background: "#FFFFFF", foreground: "#000000", diff --git a/RevenueCatUI/Modifiers/ViewExtensions.swift b/RevenueCatUI/Modifiers/ViewExtensions.swift index 81f642cabd..9b5996b429 100644 --- a/RevenueCatUI/Modifiers/ViewExtensions.swift +++ b/RevenueCatUI/Modifiers/ViewExtensions.swift @@ -43,4 +43,15 @@ extension View { } } + @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) + func scrollableIfNecessary(_ axes: Axis.Set = .vertical) -> some View { + ViewThatFits(in: axes) { + self + + ScrollView { + self + } + } + } + } diff --git a/RevenueCatUI/PaywallView.swift b/RevenueCatUI/PaywallView.swift index ee0fbe10ce..7f82cb7210 100644 --- a/RevenueCatUI/PaywallView.swift +++ b/RevenueCatUI/PaywallView.swift @@ -230,14 +230,15 @@ struct PaywallView_Previews: PreviewProvider { private static let introEligibility: TrialOrIntroEligibilityChecker = .producing(eligibility: .eligible) - .with(delay: .seconds(1)) + .with(delay: .seconds(0.5)) private static let purchaseHandler: PurchaseHandler = .mock() .with(delay: .seconds(1)) private static let offerings: [Offering] = [ TestData.offeringWithIntroOffer, - TestData.offeringWithMultiPackagePaywall + TestData.offeringWithMultiPackagePaywall, + TestData.offeringWithSinglePackageFeaturesPaywall ] private static let modes: [PaywallViewMode] = [ @@ -270,6 +271,7 @@ private extension PaywallTemplate { switch self { case .onePackageStandard: return "single" case .multiPackageBold: return "multi" + case .onePackageWithFeatures: return "features" } } diff --git a/RevenueCatUI/Templates/MultiPackageBoldTemplate.swift b/RevenueCatUI/Templates/MultiPackageBoldTemplate.swift index 4b4638540d..cf081bdc0c 100644 --- a/RevenueCatUI/Templates/MultiPackageBoldTemplate.swift +++ b/RevenueCatUI/Templates/MultiPackageBoldTemplate.swift @@ -31,8 +31,6 @@ private struct MultiPackageTemplateContent: View { @EnvironmentObject private var purchaseHandler: PurchaseHandler - @Environment(\.dismiss) - private var dismiss init(configuration: TemplateViewConfiguration, introEligibility: [Package: IntroEligibilityStatus]) { self._selectedPackage = .init(initialValue: configuration.packages.default.content) @@ -62,21 +60,15 @@ private struct MultiPackageTemplateContent: View { VStack(spacing: 10) { self.iconImage - ViewThatFits(in: .vertical) { - self.scrollableContent - - ScrollView { - self.scrollableContent - } - .scrollBounceBehaviorBasedOnSize() - } + self.scrollableContent + .scrollableIfNecessary() self.subscribeButton .padding(.horizontal) if case .fullScreen = self.configuration.mode { FooterView(configuration: self.configuration.configuration, - colors: self.configuration.colors, + color: self.configuration.colors.foregroundColor, purchaseHandler: self.purchaseHandler) } } diff --git a/RevenueCatUI/Templates/SinglePackageStandardTemplate.swift b/RevenueCatUI/Templates/OnePackageStandardTemplate.swift similarity index 93% rename from RevenueCatUI/Templates/SinglePackageStandardTemplate.swift rename to RevenueCatUI/Templates/OnePackageStandardTemplate.swift index 4755821fcc..326eabbe78 100644 --- a/RevenueCatUI/Templates/SinglePackageStandardTemplate.swift +++ b/RevenueCatUI/Templates/OnePackageStandardTemplate.swift @@ -2,7 +2,7 @@ import RevenueCat import SwiftUI @available(iOS 16.0, macOS 13.0, tvOS 16.0, *) -struct SinglePackageStandardTemplate: TemplateViewType { +struct OnePackageStandardTemplate: TemplateViewType { private let configuration: TemplateViewConfiguration @EnvironmentObject @@ -13,14 +13,14 @@ struct SinglePackageStandardTemplate: TemplateViewType { } var body: some View { - SinglePackageTemplateContent(configuration: self.configuration, - introEligibility: self.introEligibility.singleEligibility) + OnePackageTemplateContent(configuration: self.configuration, + introEligibility: self.introEligibility.singleEligibility) } } @available(iOS 16.0, macOS 13.0, tvOS 16.0, *) -private struct SinglePackageTemplateContent: View { +private struct OnePackageTemplateContent: View { private var configuration: TemplateViewConfiguration private var introEligibility: IntroEligibilityStatus? @@ -28,8 +28,6 @@ private struct SinglePackageTemplateContent: View { @EnvironmentObject private var purchaseHandler: PurchaseHandler - @Environment(\.dismiss) - private var dismiss init(configuration: TemplateViewConfiguration, introEligibility: IntroEligibilityStatus?) { self.configuration = configuration @@ -84,7 +82,7 @@ private struct SinglePackageTemplateContent: View { if case .fullScreen = self.configuration.mode { FooterView(configuration: self.configuration.configuration, - colors: self.configuration.colors, + color: self.configuration.colors.foregroundColor, purchaseHandler: self.purchaseHandler) } } diff --git a/RevenueCatUI/Templates/OnePackageWithFeaturesTemplate.swift b/RevenueCatUI/Templates/OnePackageWithFeaturesTemplate.swift new file mode 100644 index 0000000000..d28de883cf --- /dev/null +++ b/RevenueCatUI/Templates/OnePackageWithFeaturesTemplate.swift @@ -0,0 +1,192 @@ +// +// OnePackageWithFeaturesTemplate.swift +// +// +// Created by Nacho Soto on 7/26/23. +// + +import RevenueCat +import SwiftUI + +@available(iOS 16.0, macOS 13.0, tvOS 16.0, *) +struct OnePackageWithFeaturesTemplate: TemplateViewType { + + private let configuration: TemplateViewConfiguration + @EnvironmentObject + private var introEligibility: IntroEligibilityViewModel + + init(_ configuration: TemplateViewConfiguration) { + self.configuration = configuration + } + + var body: some View { + OnePackageWithFeaturesTemplateContent( + configuration: self.configuration, + introEligibility: self.introEligibility.singleEligibility + ) + } + +} + +@available(iOS 16.0, macOS 13.0, tvOS 16.0, *) +private struct OnePackageWithFeaturesTemplateContent: View { + + private var configuration: TemplateViewConfiguration + private var introEligibility: IntroEligibilityStatus? + private var localization: ProcessedLocalizedConfiguration + + @EnvironmentObject + private var purchaseHandler: PurchaseHandler + + init(configuration: TemplateViewConfiguration, introEligibility: IntroEligibilityStatus?) { + self.configuration = configuration + self.introEligibility = introEligibility + self.localization = configuration.packages.single.localization + } + + var body: some View { + ZStack { + self.background + + self.content + } + } + + private var content: some View { + VStack { + if let url = self.configuration.iconImageURL { + RemoteImage(url: url) + .frame(width: self.iconSize, height: self.iconSize) + .cornerRadius(8) + } + + Text(self.localization.title) + .font(.title) + .foregroundStyle(self.configuration.colors.foregroundColor) + .multilineTextAlignment(.center) + + Spacer() + + self.features + .scrollableIfNecessary() + + Spacer() + + IntroEligibilityStateView( + textWithNoIntroOffer: self.localization.offerDetails, + textWithIntroOffer: self.localization.offerDetailsWithIntroOffer, + introEligibility: self.introEligibility, + foregroundColor: self.configuration.colors.accent2Color + ) + .multilineTextAlignment(.center) + .font(.subheadline) + .padding(.bottom) + + PurchaseButton( + package: self.configuration.packages.single.content, + purchaseHandler: self.purchaseHandler, + colors: self.configuration.colors, + localization: self.localization, + introEligibility: self.introEligibility, + mode: self.configuration.mode + ) + .padding(.bottom) + + FooterView(configuration: self.configuration.configuration, + color: self.configuration.colors.accent2Color, + purchaseHandler: self.purchaseHandler) + } + .padding(.horizontal) + .padding(.top) + } + + private var features: some View { + VStack(spacing: 20) { + ForEach(self.localization.features, id: \.title) { feature in + FeatureView(feature: feature, colors: self.configuration.colors) + } + } + .padding(.horizontal) + } + + private var background: some View { + Rectangle() + .foregroundStyle(self.configuration.colors.backgroundColor) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .edgesIgnoringSafeArea(.all) + } + + @ScaledMetric(relativeTo: .title) + private var iconSize = 55 + +} + +@available(iOS 16.0, macOS 13.0, tvOS 16.0, *) +private struct FeatureView: View { + + let feature: PaywallData.LocalizedConfiguration.Feature + let colors: PaywallData.Configuration.Colors + + @Environment(\.dynamicTypeSize) + private var dynamicTypeSize + + var body: some View { + HStack(alignment: .top, spacing: 16) { + if self.horizontalIconLayout { + self.icon + } + + self.content + } + } + + private var icon: some View { + Circle() + .overlay { + if let iconName = self.feature.iconID, + let icon = PaywallIcon(rawValue: iconName) { + IconView(icon: icon, tint: self.colors.accent1Color) + .padding(self.iconPadding) + } + } + .foregroundColor(.black) + .frame(width: self.iconSize, height: self.iconSize) + } + + private var content: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: self.iconPadding * 2) { + if !self.horizontalIconLayout { + self.icon + } + + Text(self.feature.title) + .foregroundStyle(self.colors.foregroundColor) + .font(.headline) + .frame(maxWidth: .infinity, alignment: .leading) + } + + if let content = self.feature.content { + Text(content) + .foregroundStyle(self.colors.accent2Color) + .font(.body) + } + } + .frame(maxWidth: .infinity) + .multilineTextAlignment(.leading) + } + + /// Determines whether the icon is displayed to the left of `content`. + private var horizontalIconLayout: Bool { + return self.dynamicTypeSize < Self.cutoffForHorizontalLayout + } + + @ScaledMetric(relativeTo: .headline) + private var iconSize = 35 + + @ScaledMetric(relativeTo: .headline) + private var iconPadding = 5 + + private static let cutoffForHorizontalLayout: DynamicTypeSize = .xxxLarge + +} diff --git a/RevenueCatUI/Templates/TemplateViewType.swift b/RevenueCatUI/Templates/TemplateViewType.swift index f345763cb4..9d1a54e949 100644 --- a/RevenueCatUI/Templates/TemplateViewType.swift +++ b/RevenueCatUI/Templates/TemplateViewType.swift @@ -16,6 +16,7 @@ private extension PaywallTemplate { switch self { case .onePackageStandard: return .single case .multiPackageBold: return .multiple + case .onePackageWithFeatures: return .single } } @@ -76,9 +77,11 @@ extension PaywallData { configuration: TemplateViewConfiguration) -> some View { switch template { case .onePackageStandard: - SinglePackageStandardTemplate(configuration) + OnePackageStandardTemplate(configuration) case .multiPackageBold: MultiPackageBoldTemplate(configuration) + case .onePackageWithFeatures: + OnePackageWithFeaturesTemplate(configuration) } } diff --git a/RevenueCatUI/Views/DebugErrorView.swift b/RevenueCatUI/Views/DebugErrorView.swift index 6f6ef7d285..a28f8f10ec 100644 --- a/RevenueCatUI/Views/DebugErrorView.swift +++ b/RevenueCatUI/Views/DebugErrorView.swift @@ -78,6 +78,7 @@ struct DebugErrorView: View { ) .foregroundColor(.white) .bold() + .minimumScaleFactor(0.5) .cornerRadius(8) .shadow(radius: 8) } diff --git a/RevenueCatUI/Views/FooterView.swift b/RevenueCatUI/Views/FooterView.swift index fd0d8b6f0d..df769e031a 100644 --- a/RevenueCatUI/Views/FooterView.swift +++ b/RevenueCatUI/Views/FooterView.swift @@ -12,7 +12,7 @@ import SwiftUI struct FooterView: View { var configuration: PaywallData.Configuration - var colors: PaywallData.Configuration.Colors + var color: Color var purchaseHandler: PurchaseHandler var body: some View { @@ -41,7 +41,7 @@ struct FooterView: View { ) } } - .foregroundColor(self.colors.foregroundColor) + .foregroundColor(self.color) .font(.caption.bold()) .padding(.horizontal) } @@ -168,7 +168,7 @@ struct Footer_Previews: PreviewProvider { termsOfServiceURL: termsOfServiceURL, privacyURL: privacyURL ), - colors: TestData.colors, + color: TestData.colors.foregroundColor, purchaseHandler: Self.handler ) } diff --git a/Sources/Paywalls/PaywallTemplate.swift b/Sources/Paywalls/PaywallTemplate.swift index 9c23ef5a65..eedd452d69 100644 --- a/Sources/Paywalls/PaywallTemplate.swift +++ b/Sources/Paywalls/PaywallTemplate.swift @@ -19,6 +19,7 @@ public enum PaywallTemplate: String { // swiftlint:disable missing_docs case onePackageStandard = "one_package_standard" case multiPackageBold = "multi_package_bold" + case onePackageWithFeatures = "one_package_with_features" // swiftlint:enable missing_docs diff --git a/Tests/APITesters/SwiftAPITester/SwiftAPITester/PaywallAPI.swift b/Tests/APITesters/SwiftAPITester/SwiftAPITester/PaywallAPI.swift index fa7dc49714..69300749cc 100644 --- a/Tests/APITesters/SwiftAPITester/SwiftAPITester/PaywallAPI.swift +++ b/Tests/APITesters/SwiftAPITester/SwiftAPITester/PaywallAPI.swift @@ -148,6 +148,8 @@ func checkPaywallTemplate(_ template: PaywallTemplate) { break case .multiPackageBold: break + case .onePackageWithFeatures: + break @unknown default: break } diff --git a/Tests/RevenueCatUITests/Templates/OnePackageWithFeaturesPaywallViewTests.swift b/Tests/RevenueCatUITests/Templates/OnePackageWithFeaturesPaywallViewTests.swift new file mode 100644 index 0000000000..5611e9755e --- /dev/null +++ b/Tests/RevenueCatUITests/Templates/OnePackageWithFeaturesPaywallViewTests.swift @@ -0,0 +1,22 @@ +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 OnePackageWithFeaturesPaywallViewTests: BaseSnapshotTest { + + func testSamplePaywall() { + let view = PaywallView(offering: Self.offering.withLocalImages, + introEligibility: Self.eligibleChecker, + purchaseHandler: Self.purchaseHandler) + view.snapshot(size: Self.fullScreenSize) + } + + private static let offering = TestData.offeringWithSinglePackageFeaturesPaywall + +} + +#endif