Skip to content

Commit

Permalink
Paywalls: determine intro eligibility (#2808)
Browse files Browse the repository at this point in the history
Follow up to #2796.
This also makes `PaywallData.Configuration`'s intro strings optional.

- Injected `TrialOrIntroEligibilityChecker`
- Created mock `TrialOrIntroEligibilityChecker` for previews
- Handling state and transitions for loading eligibility
- Snapshot tests for the different new cases
- Improved `SnapshotTesting` delay management
- Expanded `DebugErrorView` to customize release behavior
  • Loading branch information
NachoSoto committed Aug 28, 2023
1 parent c35c7cc commit 66f10e4
Show file tree
Hide file tree
Showing 18 changed files with 358 additions and 71 deletions.
15 changes: 15 additions & 0 deletions RevenueCatUI/Helpers/Constants.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
//
// Constants.swift
//
//
// Created by Nacho Soto on 7/13/23.
//

import SwiftUI

@available(iOS 16.0, macOS 13.0, tvOS 16.0, *)
enum Constants {

static let defaultAnimation: Animation = .easeIn(duration: 0.2)

}
34 changes: 25 additions & 9 deletions RevenueCatUI/Helpers/DebugErrorView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,25 @@ import SwiftUI
struct DebugErrorView: View {

private let description: String
private let releaseBehavior: ReleaseBehavior

enum ReleaseBehavior {

case emptyView
case fatalError

init(_ error: Error) {
self.init((error as NSError).localizedDescription)
}

init(_ description: String) {
init(_ error: Error, releaseBehavior: ReleaseBehavior) {
self.init(
(error as NSError).localizedDescription,
releaseBehavior: releaseBehavior
)
}

init(_ description: String, releaseBehavior: ReleaseBehavior) {
self.description = description
self.releaseBehavior = releaseBehavior
}

var body: some View {
Expand All @@ -30,12 +42,16 @@ struct DebugErrorView: View {
.edgesIgnoringSafeArea(.all)
)
#else
// Fix-me: implement a proper production error screen
// appropriate for each case
EmptyView()
.onAppear {
Logger.warning("Couldn't load paywall: \(self.description)")
}
switch self.releaseBehavior {
case .emptyView:
EmptyView()
.onAppear {
Logger.warning("Couldn't load paywall: \(self.description)")
}

case let .fatalError:
fatalError(self.description)
}
#endif
}

Expand Down
12 changes: 6 additions & 6 deletions RevenueCatUI/Helpers/ProcessedLocalizedConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ struct ProcessedLocalizedConfiguration: PaywallLocalizedConfiguration {
var title: String
var subtitle: String
var callToAction: String
var callToActionWithIntroOffer: String
var callToActionWithIntroOffer: String?
var offerDetails: String
var offerDetailsWithIntroOffer: String
var offerDetailsWithIntroOffer: String?

init(
_ configuration: PaywallData.LocalizedConfiguration,
Expand All @@ -18,19 +18,19 @@ struct ProcessedLocalizedConfiguration: PaywallLocalizedConfiguration {
title: configuration.title.processed(with: dataProvider),
subtitle: configuration.subtitle.processed(with: dataProvider),
callToAction: configuration.callToAction.processed(with: dataProvider),
callToActionWithIntroOffer: configuration.callToActionWithIntroOffer.processed(with: dataProvider),
callToActionWithIntroOffer: configuration.callToActionWithIntroOffer?.processed(with: dataProvider),
offerDetails: configuration.offerDetails.processed(with: dataProvider),
offerDetailsWithIntroOffer: configuration.offerDetailsWithIntroOffer.processed(with: dataProvider)
offerDetailsWithIntroOffer: configuration.offerDetailsWithIntroOffer?.processed(with: dataProvider)
)
}

private init(
title: String,
subtitle: String,
callToAction: String,
callToActionWithIntroOffer: String,
callToActionWithIntroOffer: String?,
offerDetails: String,
offerDetailsWithIntroOffer: String
offerDetailsWithIntroOffer: String?
) {
self.title = title
self.subtitle = subtitle
Expand Down
56 changes: 56 additions & 0 deletions RevenueCatUI/Helpers/TrialOrIntroEligibilityChecker.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
//
// TrialOrIntroEligibilityChecker.swift
//
//
// Created by Nacho Soto on 7/13/23.
//

import Foundation
import RevenueCat

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

typealias Checker = @Sendable (StoreProduct) async -> IntroEligibilityStatus

let checker: Checker

convenience init(purchases: Purchases = .shared) {
self.init { product in
guard product.hasIntroDiscount else {
return .noIntroOfferExists
}

return await purchases.checkTrialOrIntroDiscountEligibility(product: product)
}
}

/// Creates an instance with a custom checker, useful for testing or previews.
init(checker: @escaping Checker) {
self.checker = checker
}

}

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

func eligibility(for product: StoreProduct) async -> IntroEligibilityStatus {
return await self.checker(product)
}

func eligibility(for package: Package) async -> IntroEligibilityStatus {
return await self.eligibility(for: package.storeProduct)
}

}

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

var hasIntroDiscount: Bool {
// Fix-me: this needs to handle other types of intro discounts
return self.introductoryDiscount != nil
}

}
23 changes: 23 additions & 0 deletions RevenueCatUI/Modifiers/ViewExtensions.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
//
// ViewExtensions.swift
//
//
// Created by Nacho Soto on 7/13/23.
//

import Foundation
import SwiftUI

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

@ViewBuilder
func hidden(if condition: Bool) -> some View {
if condition {
self.hidden()
} else {
self
}
}

}
40 changes: 35 additions & 5 deletions RevenueCatUI/PaywallView.swift
Original file line number Diff line number Diff line change
@@ -1,21 +1,40 @@
import RevenueCat
import SwiftUI

// swiftlint:disable missing_docs

/// A full-screen SwiftUI view for displaying a `PaywallData` for an `Offering`.
@available(iOS 16.0, macOS 13.0, tvOS 16.0, *)
public struct PaywallView: View {

private let offering: Offering
private let paywall: PaywallData
private let introEligibility: TrialOrIntroEligibilityChecker?

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

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

// swiftlint:disable:next missing_docs
public var body: some View {
self.paywall.createView(for: self.offering)
if let checker = self.introEligibility {
self.paywall
.createView(for: self.offering)
.environmentObject(checker)
} else {
DebugErrorView("Purchases has not been configured.",
releaseBehavior: .fatalError)
}
}

}
Expand All @@ -26,8 +45,19 @@ public struct PaywallView: View {
struct PaywallView_Previews: PreviewProvider {

static var previews: some View {
let offering = TestData.offeringWithIntroOffer
PaywallView(offering: offering, paywall: offering.paywall!)
let offering = TestData.offeringWithNoIntroOffer

if let paywall = offering.paywall {
PaywallView(
offering: offering,
paywall: paywall,
introEligibility: TrialOrIntroEligibilityChecker
.producing(eligibility: .eligible)
.with(delay: .seconds(1))
)
} else {
Text("Preview not correctly setup, offering has no paywall!")
}
}

}
Expand Down
Loading

0 comments on commit 66f10e4

Please sign in to comment.