Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Paywalls: added FooterView #2850

Merged
merged 1 commit into from
Jul 24, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -91,6 +91,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
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in #2851

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
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading