Skip to content

Commit

Permalink
Paywalls: added FooterView (#2850)
Browse files Browse the repository at this point in the history
This adds 3 new features: restore purchases, ToS, and privacy policy.
All configurable.

![image](https://github.com/RevenueCat/purchases-ios/assets/685609/b38dadec-9fa1-4d48-876d-b53d0dda93d9)
  • Loading branch information
NachoSoto committed Sep 15, 2023
1 parent ef88770 commit b09c617
Show file tree
Hide file tree
Showing 9 changed files with 282 additions and 12 deletions.
21 changes: 16 additions & 5 deletions RevenueCatUI/Data/TestData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,9 @@ internal enum TestData {
config: .init(
packages: [.monthly],
imageNames: [Self.paywallHeaderImageName],
colors: .init(light: Self.lightColors, dark: Self.darkColors)
colors: .init(light: Self.lightColors, dark: Self.darkColors),
termsOfServiceURL: URL(string: "https://revenuecat.com/tos")!,
privacyURL: URL(string: "https://revenuecat.com/privacy")!
),
localization: Self.localization1,
assetBaseURL: Self.paywallAssetBaseURL
Expand Down Expand Up @@ -174,7 +176,9 @@ internal enum TestData {
callToActionBackground: "#ACD27A",
callToActionForeground: "#000000"
)
)
),
termsOfServiceURL: URL(string: "https://revenuecat.com/tos")!,
privacyURL: URL(string: "https://revenuecat.com/tos")!
),
localization: Self.localization2,
assetBaseURL: Self.paywallAssetBaseURL
Expand Down Expand Up @@ -308,16 +312,23 @@ extension PurchaseHandler {
customerInfo: TestData.customerInfo,
userCancelled: false
)
} restorePurchases: {
return TestData.customerInfo
}
}

/// Creates a copy of this `PurchaseHandler` with a delay.
func with(delay: Duration) -> Self {
return self.map { purchaseBlock in {
try? await Task.sleep(for: delay)
try? await Task.sleep(for: delay)

return try await purchaseBlock($0)
}
return try await purchaseBlock($0)
}
} restore: { restoreBlock in {
try? await Task.sleep(for: delay)

return try await restoreBlock()
}
}
}
}
Expand Down
24 changes: 20 additions & 4 deletions RevenueCatUI/Purchasing/PurchaseHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,20 +13,28 @@ import SwiftUI
final class PurchaseHandler: ObservableObject {

typealias PurchaseBlock = @Sendable (Package) async throws -> PurchaseResultData
typealias RestoreBlock = @Sendable () async throws -> CustomerInfo

private let purchaseBlock: PurchaseBlock
private let restoreBlock: RestoreBlock

@Published
var purchased: Bool = false

convenience init(purchases: Purchases = .shared) {
self.init { package in
return try await purchases.purchase(package: package)
} restorePurchases: {
return try await purchases.restorePurchases()
}
}

init(purchase: @escaping PurchaseBlock) {
init(
purchase: @escaping PurchaseBlock,
restorePurchases: @escaping RestoreBlock
) {
self.purchaseBlock = purchase
self.restoreBlock = restorePurchases
}

}
Expand All @@ -46,9 +54,17 @@ extension PurchaseHandler {
return result
}

/// Creates a copy of this `PurchaseHandler` wrapping the purchase block
func map(_ block: @escaping (@escaping PurchaseBlock) -> PurchaseBlock) -> Self {
return .init(purchase: block(self.purchaseBlock))
func restorePurchases() async throws -> CustomerInfo {
return try await self.restoreBlock()
}

/// Creates a copy of this `PurchaseHandler` wrapping the purchase and restore blocks.
func map(
purchase: @escaping (@escaping PurchaseBlock) -> PurchaseBlock,
restore: @escaping (@escaping RestoreBlock) -> RestoreBlock
) -> Self {
return .init(purchase: purchase(self.purchaseBlock),
restorePurchases: restore(self.restoreBlock))
}

}
7 changes: 6 additions & 1 deletion RevenueCatUI/Templates/MultiPackageTemplate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,13 @@ private struct MultiPackageTemplateContent: View {
}

self.subscribeButton
.padding(.bottom)
.padding(.horizontal)

if case .fullScreen = self.configuration.mode {
FooterView(configuration: self.configuration.configuration,
colors: self.configuration.colors,
purchaseHandler: self.purchaseHandler)
}
}
.animation(.easeInOut(duration: 0.1), value: self.selectedPackage)
.frame(maxHeight: .infinity)
Expand Down
6 changes: 6 additions & 0 deletions RevenueCatUI/Templates/SinglePackageTemplate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,12 @@ private struct SinglePackageTemplateContent: View {

self.button
.padding(.horizontal)

if case .fullScreen = self.configuration.mode {
FooterView(configuration: self.configuration.configuration,
colors: self.configuration.colors,
purchaseHandler: self.purchaseHandler)
}
}
}

Expand Down
174 changes: 174 additions & 0 deletions RevenueCatUI/Views/FooterView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
//
// FooterView.swift
//
//
// Created by Nacho Soto on 7/20/23.
//

import RevenueCat
import SwiftUI

@available(iOS 16.0, macOS 13.0, tvOS 16.0, *)
struct FooterView: View {

var configuration: PaywallData.Configuration
var colors: PaywallData.Configuration.Colors
var purchaseHandler: PurchaseHandler

var body: some View {
HStack {
if self.configuration.displayRestorePurchases {
RestorePurchasesButton(purchaseHandler: self.purchaseHandler)

self.separator
.hidden(if: !self.hasTOS && !self.hasPrivacy)
}

if let url = self.configuration.termsOfServiceURL {
LinkButton(
url: url,
// Fix-me: localize
titles: "Terms and conditions", "Terms"
)

self.separator
.hidden(if: !self.hasPrivacy)
}

if let url = self.configuration.privacyURL {
LinkButton(
url: url,
// Fix-me: localize
titles: "Privacy policy", "Privacy"
)
}
}
.foregroundColor(self.colors.foregroundColor)
.font(.caption.bold())
.padding(.horizontal)
}

private var separator: some View {
Image(systemName: "circle.fill")
.font(.system(size: 5))
}

private var hasTOS: Bool { self.configuration.termsOfServiceURL != nil }
private var hasPrivacy: Bool { self.configuration.privacyURL != nil }

}

@available(iOS 16.0, macOS 13.0, tvOS 16.0, *)
private struct RestorePurchasesButton: View {

let purchaseHandler: PurchaseHandler

@State
private var restored = false

var body: some View {
AsyncButton {
_ = try await self.purchaseHandler.restorePurchases()
self.restored = true
} label: {
// Fix-me: localize
ViewThatFits {
Text("Restore purchases")
Text("Restore")
}
}
.buttonStyle(.plain)
.alert(isPresented: self.$restored) {
// Fix-me: localize
Alert(title: Text("Purchases restored successfully!"))
}
}

}

@available(iOS 16.0, macOS 13.0, tvOS 16.0, *)
private struct LinkButton: View {

let url: URL
let titles: [String]

init(url: URL, titles: String...) {
self.url = url
self.titles = titles
}

var body: some View {
ViewThatFits {
ForEach(self.titles, id: \.self) { title in
Link(title, destination: self.url)
}
}
}

}

#if DEBUG

@available(iOS 16.0, macOS 13.0, tvOS 16.0, *)
@available(watchOS, unavailable)
@available(macOS, unavailable)
@available(macCatalyst, unavailable)
struct Footer_Previews: PreviewProvider {

static var previews: some View {
Self.create(
displayRestorePurchases: false
)
.previewDisplayName("Empty")

Self.create(
displayRestorePurchases: true
)
.previewDisplayName("Only Restore")

Self.create(
displayRestorePurchases: false,
termsOfServiceURL: URL(string: "https://revenuecat.com/tos")!
)
.previewDisplayName("TOS")

Self.create(
displayRestorePurchases: true,
termsOfServiceURL: URL(string: "https://revenuecat.com/tos")!
)
.previewDisplayName("Restore + TOS")

Self.create(
displayRestorePurchases: true,
termsOfServiceURL: URL(string: "https://revenuecat.com/tos")!,
privacyURL: URL(string: "https://revenuecat.com/tos")!
)
.previewDisplayName("All")
}

private static func create(
displayRestorePurchases: Bool = true,
termsOfServiceURL: URL? = nil,
privacyURL: URL? = nil
) -> some View {
FooterView(
configuration: .init(
packages: [],
imageNames: ["image"],
colors: .init(light: TestData.lightColors, dark: TestData.darkColors),
displayRestorePurchases: displayRestorePurchases,
termsOfServiceURL: termsOfServiceURL,
privacyURL: privacyURL
),
colors: TestData.colors,
purchaseHandler: Self.handler
)
}

private static let handler: PurchaseHandler =
.mock()
.with(delay: .seconds(0.5))

}

#endif
42 changes: 41 additions & 1 deletion Sources/Paywalls/PaywallData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -143,21 +143,58 @@ extension PaywallData {
set { self._imageNames = newValue }
}

/// Whether a restore purchases button should be displayed.
public var displayRestorePurchases: Bool {
get { self._displayRestorePurchases }
set { self._displayRestorePurchases = newValue }
}

/// If set, the paywall will display a terms of service link.
public var termsOfServiceURL: URL? {
get { self._termsOfServiceURL }
set { self._termsOfServiceURL = newValue }
}

/// If set, the paywall will display a privacy policy link.
public var privacyURL: URL? {
get { self._privacyURL }
set { self._privacyURL = newValue }
}

/// The set of colors used
public var colors: ColorInformation

// swiftlint:disable:next missing_docs
public init(packages: [PackageType], imageNames: [String], colors: ColorInformation) {
public init(
packages: [PackageType],
imageNames: [String],
colors: ColorInformation,
displayRestorePurchases: Bool = true,
termsOfServiceURL: URL? = nil,
privacyURL: URL? = nil
) {
assert(!imageNames.isEmpty)

self.packages = packages
self._imageNames = imageNames
self.colors = colors
self._displayRestorePurchases = displayRestorePurchases
self._termsOfServiceURL = termsOfServiceURL
self._privacyURL = privacyURL
}

@EnsureNonEmptyArrayDecodable
var _imageNames: [String]

@DefaultDecodable.True
var _displayRestorePurchases: Bool

@IgnoreDecodeErrors<URL?>
var _termsOfServiceURL: URL?

@IgnoreDecodeErrors<URL?>
var _privacyURL: URL?

}

}
Expand Down Expand Up @@ -283,6 +320,9 @@ extension PaywallData.Configuration: Codable {
private enum CodingKeys: String, CodingKey {
case packages
case _imageNames = "images"
case _displayRestorePurchases = "displayRestorePurchases"
case _termsOfServiceURL = "tosUrl"
case _privacyURL = "privacyUrl"
case colors
}

Expand Down
14 changes: 13 additions & 1 deletion Tests/APITesters/SwiftAPITester/SwiftAPITester/PaywallAPI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,21 @@ func checkPaywallData(_ data: PaywallData) {

func checkPaywallConfiguration(_ config: PaywallData.Configuration,
_ colors: PaywallData.Configuration.ColorInformation) {
let _: PaywallData.Configuration = .init(packages: [.monthly, .annual], imageNames: [""], colors: colors)
let _: PaywallData.Configuration = .init(packages: [.monthly, .annual],
imageNames: [""],
colors: colors)
let _: PaywallData.Configuration = .init(packages: [.monthly, .annual],
imageNames: [""],
colors: colors,
displayRestorePurchases: true,
termsOfServiceURL: URL(string: ""),
privacyURL: URL(string: ""))
let _: [PackageType] = config.packages
let _: [String] = config.imageNames
let _: PaywallData.Configuration.ColorInformation = config.colors
let _: Bool = config.displayRestorePurchases
let _: URL? = config.termsOfServiceURL
let _: URL? = config.privacyURL
}

func checkPaywallLocalizedConfig(_ config: PaywallData.LocalizedConfiguration) {
Expand Down
Loading

0 comments on commit b09c617

Please sign in to comment.