Skip to content

Commit

Permalink
Paywalls: one package with features template (#2902)
Browse files Browse the repository at this point in the history
Depends on #2882.

<img width="391" alt="Screenshot 2023-07-26 at 16 49 26"
src="https://github.com/RevenueCat/purchases-ios/assets/685609/4daf9dbf-0731-436c-a0b5-e9387f59f1e0">
  • Loading branch information
NachoSoto committed Aug 31, 2023
1 parent db1531d commit 767b63c
Show file tree
Hide file tree
Showing 12 changed files with 295 additions and 25 deletions.
48 changes: 47 additions & 1 deletion RevenueCatUI/Data/TestData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import Foundation
import RevenueCat

// swiftlint:disable type_body_length
// swiftlint:disable type_body_length file_length

#if DEBUG

Expand Down Expand Up @@ -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",
Expand Down
11 changes: 11 additions & 0 deletions RevenueCatUI/Modifiers/ViewExtensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}

}
6 changes: 4 additions & 2 deletions RevenueCatUI/PaywallView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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] = [
Expand Down Expand Up @@ -270,6 +271,7 @@ private extension PaywallTemplate {
switch self {
case .onePackageStandard: return "single"
case .multiPackageBold: return "multi"
case .onePackageWithFeatures: return "features"
}
}

Expand Down
14 changes: 3 additions & 11 deletions RevenueCatUI/Templates/MultiPackageBoldTemplate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -13,23 +13,21 @@ 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?
private var localization: ProcessedLocalizedConfiguration

@EnvironmentObject
private var purchaseHandler: PurchaseHandler
@Environment(\.dismiss)
private var dismiss

init(configuration: TemplateViewConfiguration, introEligibility: IntroEligibilityStatus?) {
self.configuration = configuration
Expand Down Expand Up @@ -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)
}
}
Expand Down
192 changes: 192 additions & 0 deletions RevenueCatUI/Templates/OnePackageWithFeaturesTemplate.swift
Original file line number Diff line number Diff line change
@@ -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

}
5 changes: 4 additions & 1 deletion RevenueCatUI/Templates/TemplateViewType.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ private extension PaywallTemplate {
switch self {
case .onePackageStandard: return .single
case .multiPackageBold: return .multiple
case .onePackageWithFeatures: return .single
}
}

Expand Down Expand Up @@ -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)
}
}

Expand Down
Loading

0 comments on commit 767b63c

Please sign in to comment.