Skip to content

Commit

Permalink
Paywalls: extracted PurchaseButton (#2839)
Browse files Browse the repository at this point in the history
Another small refactor, follow up to #2837.
Slowly taking implementation details from `Example1Template` to make it
easier to implement more templates.
  • Loading branch information
NachoSoto committed Jul 24, 2023
1 parent b0e48af commit 55cf1d5
Show file tree
Hide file tree
Showing 7 changed files with 207 additions and 73 deletions.
16 changes: 11 additions & 5 deletions RevenueCatUI/Data/TestData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import RevenueCat

#if DEBUG

@available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *)
internal enum TestData {

static let productWithIntroOffer = TestStoreProduct(
Expand Down Expand Up @@ -112,6 +113,10 @@ internal enum TestData {
callToActionForeground: "#000000"
)

#if canImport(SwiftUI) && canImport(UIKit)
static let colors: PaywallData.Configuration.Colors = .combine(light: Self.lightColors, dark: Self.darkColors)
#endif

static let customerInfo: CustomerInfo = {
let json = """
{
Expand Down Expand Up @@ -145,11 +150,11 @@ internal enum TestData {
return try! decoder.decode(CustomerInfo.self, from: Data(json.utf8))
}()

private static let localization: PaywallData.LocalizedConfiguration = .init(
static let localization: PaywallData.LocalizedConfiguration = .init(
title: "Ignite your child's curiosity",
subtitle: "Get access to all our educational content trusted by thousands of parents.",
callToAction: "Purchase for {{ price }}",
callToActionWithIntroOffer: nil,
callToActionWithIntroOffer: "Purchase for {{ price_per_month }} per month",
offerDetails: "{{ price_per_month }} per month",
offerDetailsWithIntroOffer: "Start your {{ intro_duration }} trial, then {{ price_per_month }} per month"
)
Expand Down Expand Up @@ -198,10 +203,11 @@ extension PurchaseHandler {

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

return try await purchaseBlock($0)
return try await purchaseBlock($0)
}
}
}
}
Expand Down
9 changes: 9 additions & 0 deletions RevenueCatUI/Helpers/ColorInformation+MultiScheme.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,15 @@ extension PaywallData.Configuration.ColorInformation {
return light
}

return .combine(light: light, dark: dark)
}

}

extension PaywallData.Configuration.Colors {

@available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *)
static func combine(light: Self, dark: Self) -> Self {
return .init(
background: .init(light: light.background, dark: dark.background),
foreground: .init(light: light.foreground, dark: dark.foreground),
Expand Down
15 changes: 14 additions & 1 deletion RevenueCatUI/PaywallView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,9 @@ private struct LoadedOfferingPaywallView: View {
private let paywall: PaywallData
private let mode: PaywallViewMode
private let introEligibility: TrialOrIntroEligibilityChecker
private let purchaseHandler: PurchaseHandler

@ObservedObject
private var purchaseHandler: PurchaseHandler

init(
offering: Offering,
Expand All @@ -121,6 +123,7 @@ private struct LoadedOfferingPaywallView: View {
.createView(for: self.offering, mode: self.mode)
.environmentObject(self.introEligibility)
.environmentObject(self.purchaseHandler)
.hidden(if: self.shouldHidePaywall)

if let aspectRatio = self.mode.aspectRatio {
view.aspectRatio(aspectRatio, contentMode: .fit)
Expand All @@ -129,6 +132,16 @@ private struct LoadedOfferingPaywallView: View {
}
}

private var shouldHidePaywall: Bool {
switch self.mode {
case .fullScreen:
return false

case .card, .banner:
return self.purchaseHandler.purchased
}
}

}

// MARK: - Extensions
Expand Down
23 changes: 20 additions & 3 deletions RevenueCatUI/Purchasing/PurchaseHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,17 @@

import RevenueCat
import StoreKit
import SwiftUI

@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.2, *)
final class PurchaseHandler: ObservableObject {

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

let purchaseBlock: PurchaseBlock
private let purchaseBlock: PurchaseBlock

@Published
var purchased: Bool = false

convenience init(purchases: Purchases = .shared) {
self.init { package in
Expand All @@ -27,11 +31,24 @@ final class PurchaseHandler: ObservableObject {

}

@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.2, *)
@available(iOS 16.0, macOS 13.0, tvOS 16.0, *)
extension PurchaseHandler {

func purchase(package: Package) async throws -> PurchaseResultData {
return try await self.purchaseBlock(package)
let result = try await self.purchaseBlock(package)

if !result.userCancelled {
withAnimation(Constants.defaultAnimation) {
self.purchased = true
}
}

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))
}

}
71 changes: 8 additions & 63 deletions RevenueCatUI/Templates/Example1Template.swift
Original file line number Diff line number Diff line change
Expand Up @@ -146,35 +146,14 @@ private struct Example1TemplateContent: View {

@ViewBuilder
private var button: some View {
let package = self.configuration.packages.single.content

AsyncButton { @MainActor in
let cancelled = try await self.purchaseHandler.purchase(package: package).userCancelled

if !cancelled {
self.dismiss()
}
} label: {
IntroEligibilityStateView(
textWithNoIntroOffer: self.localization.callToAction,
textWithIntroOffer: self.localization.callToActionWithIntroOffer,
introEligibility: self.introEligibility
)
.foregroundColor(self.configuration.colors.callToActionForegroundColor)
.tint(self.configuration.colors.callToActionForegroundColor)
.frame(
maxWidth: self.configuration.mode.fullWidthButton
? .infinity
: nil
)
}
.font(self.configuration.mode.buttonFont)
.fontWeight(.semibold)
.tint(self.configuration.colors.callToActionBackgroundColor.gradient)
.buttonStyle(.borderedProminent)
.buttonBorderShape(self.configuration.mode.buttonBorderShape)
.controlSize(self.configuration.mode.buttonSize)
.frame(maxWidth: .infinity)
PurchaseButton(
package: self.configuration.packages.single.content,
purchaseHandler: self.purchaseHandler,
colors: self.configuration.colors,
localization: self.localization,
introEligibility: self.introEligibility,
mode: self.configuration.mode
)
}

private static let imageAspectRatio = 1.1
Expand Down Expand Up @@ -222,38 +201,4 @@ private extension PaywallViewMode {
}
}

var buttonFont: Font {
switch self {
case .fullScreen, .card: return .title2
case .banner: return .footnote
}
}

var fullWidthButton: Bool {
switch self {
case .fullScreen, .card: return true
case .banner: return false
}
}

var buttonSize: ControlSize {
switch self {
case .fullScreen: return .large
case .card: return .regular
case .banner: return .small
}
}

var buttonBorderShape: ButtonBorderShape {
switch self {
case .fullScreen:
#if os(macOS)
return .roundedRectangle
#else
return .capsule
#endif
case .card, .banner: return .roundedRectangle
}
}

}
2 changes: 1 addition & 1 deletion RevenueCatUI/Views/AsyncButton.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import SwiftUI
@available(iOS 16.0, macOS 13.0, tvOS 16.0, *)
struct AsyncButton<Label>: View where Label: View {

typealias Action = @Sendable () async throws -> Void
typealias Action = @Sendable @MainActor () async throws -> Void

private let action: Action
private let label: Label
Expand Down
144 changes: 144 additions & 0 deletions RevenueCatUI/Views/PurchaseButton.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
//
// PurchaseButton.swift
//
//
// Created by Nacho Soto on 7/18/23.
//

import RevenueCat
import SwiftUI

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

let package: Package
let purchaseHandler: PurchaseHandler
let colors: PaywallData.Configuration.Colors
let localization: ProcessedLocalizedConfiguration
let introEligibility: IntroEligibilityStatus?
let mode: PaywallViewMode

@Environment(\.dismiss)
private var dismiss

var body: some View {
self.button
}

private var button: some View {
AsyncButton {
let cancelled = try await self.purchaseHandler.purchase(package: self.package).userCancelled

if !cancelled, case .fullScreen = self.mode {
self.dismiss()
}
} label: {
IntroEligibilityStateView(
textWithNoIntroOffer: self.localization.callToAction,
textWithIntroOffer: self.localization.callToActionWithIntroOffer,
introEligibility: self.introEligibility
)
.foregroundColor(self.colors.callToActionForegroundColor)
.tint(self.colors.callToActionForegroundColor)
.frame(
maxWidth: self.mode.fullWidthButton
? .infinity
: nil
)
}
.font(self.mode.buttonFont.weight(.semibold))
.tint(self.colors.callToActionBackgroundColor.gradient)
.buttonBorderShape(self.mode.buttonBorderShape)
.controlSize(self.mode.buttonSize)
.buttonStyle(.borderedProminent)
.frame(maxWidth: .infinity)
}

}

@available(iOS 16.0, macOS 13.0, tvOS 16.0, *)
private extension PaywallViewMode {

var buttonFont: Font {
switch self {
case .fullScreen, .card: return .title2
case .banner: return .footnote
}
}

var fullWidthButton: Bool {
switch self {
case .fullScreen, .card: return true
case .banner: return false
}
}

var buttonSize: ControlSize {
switch self {
case .fullScreen: return .large
case .card: return .regular
case .banner: return .small
}
}

var buttonBorderShape: ButtonBorderShape {
switch self {
case .fullScreen:
#if os(macOS)
return .roundedRectangle
#else
return .capsule
#endif
case .card, .banner:
return .roundedRectangle
}
}

}

// MARK: - Previews

#if DEBUG && canImport(SwiftUI) && canImport(UIKit)

@available(iOS 16.0, macOS 13.0, tvOS 16.0, *)
struct PurchaseButton_Previews: PreviewProvider {

private struct Preview: View {

var mode: PaywallViewMode

@State
private var eligibility: IntroEligibilityStatus?

var body: some View {
PurchaseButton(
package: Self.package,
purchaseHandler: Self.purchaseHandler,
colors: TestData.colors,
localization: TestData.localization.processVariables(with: Self.package),
introEligibility: self.eligibility,
mode: self.mode
)
.task {
self.eligibility = await Self.eligibilityChecker.eligibility(for: Self.package)
}
}

private static let package: Package = TestData.packageWithIntroOffer
private static let eligibilityChecker: TrialOrIntroEligibilityChecker = .producing(eligibility: .eligible)
.with(delay: .seconds(0.3))
private static let purchaseHandler: PurchaseHandler = .mock()
.with(delay: .seconds(0.5))

}

static var previews: some View {
ForEach(PaywallViewMode.allCases, id: \.self) { mode in
Preview(mode: mode)
.previewLayout(.sizeThatFits)
}
}

}

#endif

0 comments on commit 55cf1d5

Please sign in to comment.