Skip to content

Commit

Permalink
Paywalls: added support for purchasing (#2812)
Browse files Browse the repository at this point in the history
This adds a new `PurchaseHandler` type, which can be instantiated with
`Purchases` or with a mock implementation for previews. That gets
injected, and templates can now purchase and dismiss themselves.

I've improved the `SimpleApp` setup to better work with the lifetime of
purchases. Now it only displays the paywall if the user doesn't have an
entitlement.

I've also added some basic error handling to the new `AsyncButton`:
![Simulator Screenshot - iPhone 14 Pro - 2023-07-13 at 15 32
06](https://github.com/RevenueCat/purchases-ios/assets/685609/e8c2e6e3-f1e0-411f-9ca2-8a3cc3823a42)
  • Loading branch information
NachoSoto committed Aug 24, 2023
1 parent 2658546 commit 8aba798
Show file tree
Hide file tree
Showing 15 changed files with 438 additions and 48 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
"location" : "https://github.com/RevenueCat/purchases-ios",
"state" : {
"branch" : "main",
"revision" : "e65912c9020cd27cbc14a80d2054b06a4f8b6331"
"revision" : "fb15ee6b8f25f9386692dc22d00364acfeed8432"
}
},
{
Expand Down
17 changes: 14 additions & 3 deletions RevenueCatUI/PaywallView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,29 +8,38 @@ public struct PaywallView: View {
private let offering: Offering
private let paywall: PaywallData
private let introEligibility: TrialOrIntroEligibilityChecker?
private let purchaseHandler: PurchaseHandler?

/// Create a view for the given offering and paywal.
/// - Warning: `Purchases` must have been configured prior to displaying it.
public init(offering: Offering, paywall: PaywallData) {
self.init(
offering: offering,
paywall: paywall,
introEligibility: Purchases.isConfigured ? .init() : nil
introEligibility: Purchases.isConfigured ? .init() : nil,
purchaseHandler: Purchases.isConfigured ? .init() : nil
)
}

init(offering: Offering, paywall: PaywallData, introEligibility: TrialOrIntroEligibilityChecker?) {
init(
offering: Offering,
paywall: PaywallData,
introEligibility: TrialOrIntroEligibilityChecker?,
purchaseHandler: PurchaseHandler?
) {
self.offering = offering
self.paywall = paywall
self.introEligibility = introEligibility
self.purchaseHandler = purchaseHandler
}

// swiftlint:disable:next missing_docs
public var body: some View {
if let checker = self.introEligibility {
if let checker = self.introEligibility, let purchaseHandler = self.purchaseHandler {
self.paywall
.createView(for: self.offering)
.environmentObject(checker)
.environmentObject(purchaseHandler)
} else {
DebugErrorView("Purchases has not been configured.",
releaseBehavior: .fatalError)
Expand All @@ -53,6 +62,8 @@ struct PaywallView_Previews: PreviewProvider {
paywall: paywall,
introEligibility: TrialOrIntroEligibilityChecker
.producing(eligibility: .eligible)
.with(delay: .seconds(1)),
purchaseHandler: .mock()
.with(delay: .seconds(1))
)
} else {
Expand Down
37 changes: 37 additions & 0 deletions RevenueCatUI/Purchasing/PurchaseHandler.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
//
// PurchaseHandler.swift
//
//
// Created by Nacho Soto on 7/13/23.
//

import RevenueCat
import StoreKit

@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

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

init(purchase: @escaping PurchaseBlock) {
self.purchaseBlock = purchase
}

}

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

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

}
11 changes: 10 additions & 1 deletion RevenueCatUI/Templates/Example1Template.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,11 @@ private struct Example1TemplateContent: View {
private var data: Data
private var introEligibility: IntroEligibilityStatus?

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

init(data: Data, introEligibility: IntroEligibilityStatus?) {
self.data = data
self.introEligibility = introEligibility
Expand Down Expand Up @@ -173,8 +178,12 @@ private struct Example1TemplateContent: View {

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

if !cancelled {
await self.dismiss()
}
} label: {
self.ctaText
.frame(maxWidth: .infinity)
Expand Down
58 changes: 57 additions & 1 deletion RevenueCatUI/TestData.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
//
// File.swift
// TestData.swift
//
//
// Created by Nacho Soto on 7/6/23.
Expand Down Expand Up @@ -97,6 +97,39 @@ internal enum TestData {
availablePackages: Self.packages
)

static let customerInfo: CustomerInfo = {
let json = """
{
"schema_version": "4",
"request_date": "2022-03-08T17:42:58Z",
"request_date_ms": 1646761378845,
"subscriber": {
"first_seen": "2022-03-08T17:42:58Z",
"last_seen": "2022-03-08T17:42:58Z",
"management_url": "https://apps.apple.com/account/subscriptions",
"non_subscriptions": {
},
"original_app_user_id": "$RCAnonymousID:5b6fdbac3a0c4f879e43d269ecdf9ba1",
"original_application_version": "1.0",
"original_purchase_date": "2022-04-12T00:03:24Z",
"other_purchases": {
},
"subscriptions": {
},
"entitlements": {
}
}
}
"""

let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
decoder.dateDecodingStrategy = .iso8601

// swiftlint:disable:next force_try
return try! decoder.decode(CustomerInfo.self, from: Data(json.utf8))
}()

private 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.",
Expand Down Expand Up @@ -135,4 +168,27 @@ extension TrialOrIntroEligibilityChecker {

}

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

static func mock() -> Self {
return self.init { _ in
return (
transaction: nil,
customerInfo: TestData.customerInfo,
userCancelled: false
)
}
}

/// 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 try await purchaseBlock($0)
}
}
}

#endif
95 changes: 95 additions & 0 deletions RevenueCatUI/Views/AsyncButton.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
//
// AsyncButton.swift
//
//
// Created by Nacho Soto on 7/13/23.
//

import RevenueCat
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

private let action: Action
private let label: Label

@State
private var error: LocalizedAlertError?

@State
private var inProgress: Bool = false

init(action: @escaping Action, @ViewBuilder label: () -> Label) {
self.action = action
self.label = label()
}

var body: some View {
Button {
Task<Void, Never> {
self.inProgress = true
defer { self.inProgress = false }

do {
try await self.action()
} catch let error as NSError {
self.error = .init(error: error)
}
}
} label: {
self.label
}
.disabled(self.inProgress)
.alert(isPresented: self.isShowingError, error: self.error) { _ in
Button {
self.error = nil
} label: {
Text("OK")
}
} message: { error in
Text(error.failureReason ?? "")
}
}

private var isShowingError: Binding<Bool> {
return .init {
self.error != nil
} set: { newValue in
if !newValue {
self.error = nil
}
}
}

}

// MARK: - Errors

private struct LocalizedAlertError: LocalizedError {

private let underlyingError: NSError

init(error: NSError) {
self.underlyingError = error
}

var errorDescription: String? {
return "\(self.underlyingError.domain) \(self.underlyingError.code)"
}

var failureReason: String? {
if let errorCode = self.underlyingError as? ErrorCode {
return errorCode.description
} else {
return self.underlyingError.localizedDescription
}
}

var recoverySuggestion: String? {
self.underlyingError.localizedRecoverySuggestion
}

}
File renamed without changes.
15 changes: 11 additions & 4 deletions Tests/RevenueCatUITests/PaywallViewTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ class PaywallViewTests: TestCase {

let view = PaywallView(offering: offering,
paywall: offering.paywallWithLocalImage,
introEligibility: Self.eligibleChecker)
introEligibility: Self.eligibleChecker,
purchaseHandler: Self.purchaseHandler)

view.snapshot(size: Self.size)
}
Expand All @@ -28,7 +29,8 @@ class PaywallViewTests: TestCase {

let view = PaywallView(offering: offering,
paywall: offering.paywallWithLocalImage,
introEligibility: Self.eligibleChecker)
introEligibility: Self.eligibleChecker,
purchaseHandler: Self.purchaseHandler)

view.snapshot(size: Self.size)
}
Expand All @@ -38,7 +40,8 @@ class PaywallViewTests: TestCase {

let view = PaywallView(offering: offering,
paywall: offering.paywallWithLocalImage,
introEligibility: Self.ineligibleChecker)
introEligibility: Self.ineligibleChecker,
purchaseHandler: Self.purchaseHandler)

view.snapshot(size: Self.size)
}
Expand All @@ -50,19 +53,23 @@ class PaywallViewTests: TestCase {
offering: offering,
paywall: offering.paywallWithLocalImage,
introEligibility: Self.ineligibleChecker
.with(delay: .seconds(30))
.with(delay: .seconds(30)),
purchaseHandler: Self.purchaseHandler
)

view.snapshot(size: Self.size)
}

private static let eligibleChecker: TrialOrIntroEligibilityChecker = .producing(eligibility: .eligible)
private static let ineligibleChecker: TrialOrIntroEligibilityChecker = .producing(eligibility: .ineligible)
private static let purchaseHandler: PurchaseHandler = .mock()

private static let size: CGSize = .init(width: 460, height: 950)

}

// MARK: - Extensions

private extension Offering {

var paywallWithLocalImage: PaywallData {
Expand Down
Loading

0 comments on commit 8aba798

Please sign in to comment.