diff --git a/RevenueCatUI/Data/IntroEligibilityViewModel.swift b/RevenueCatUI/Data/IntroEligibility/IntroEligibilityViewModel.swift similarity index 100% rename from RevenueCatUI/Data/IntroEligibilityViewModel.swift rename to RevenueCatUI/Data/IntroEligibility/IntroEligibilityViewModel.swift diff --git a/RevenueCatUI/Data/IntroEligibility/TrialOrIntroEligibilityChecker+TestData.swift b/RevenueCatUI/Data/IntroEligibility/TrialOrIntroEligibilityChecker+TestData.swift new file mode 100644 index 0000000000..668b324de1 --- /dev/null +++ b/RevenueCatUI/Data/IntroEligibility/TrialOrIntroEligibilityChecker+TestData.swift @@ -0,0 +1,49 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// TrialOrIntroEligibilityChecker+TestData.swift +// +// Created by Nacho Soto on 9/12/23. + +import Foundation +import RevenueCat + +#if DEBUG + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, *) +extension TrialOrIntroEligibilityChecker { + + /// Creates a mock `TrialOrIntroEligibilityChecker` with a constant result. + static func producing(eligibility: @autoclosure @escaping () -> IntroEligibilityStatus) -> Self { + return .init { packages in + return Dictionary( + uniqueKeysWithValues: Set(packages) + .map { package in + let result = package.storeProduct.hasIntroDiscount + ? eligibility() + : .noIntroOfferExists + + return (package, result) + } + ) + } + } + + /// Creates a copy of this `TrialOrIntroEligibilityChecker` with a delay. + func with(delay seconds: TimeInterval) -> Self { + return .init { [checker = self.checker] in + await Task.sleep(seconds: seconds) + + return await checker($0) + } + } + +} + +#endif diff --git a/RevenueCatUI/Helpers/TrialOrIntroEligibilityChecker.swift b/RevenueCatUI/Data/IntroEligibility/TrialOrIntroEligibilityChecker.swift similarity index 100% rename from RevenueCatUI/Helpers/TrialOrIntroEligibilityChecker.swift rename to RevenueCatUI/Data/IntroEligibility/TrialOrIntroEligibilityChecker.swift diff --git a/RevenueCatUI/Data/TestData.swift b/RevenueCatUI/Data/TestData.swift index bede710de5..73220396b6 100644 --- a/RevenueCatUI/Data/TestData.swift +++ b/RevenueCatUI/Data/TestData.swift @@ -519,80 +519,6 @@ internal enum TestData { } } -@available(iOS 15.0, macOS 12.0, tvOS 15.0, *) -extension TrialOrIntroEligibilityChecker { - - /// Creates a mock `TrialOrIntroEligibilityChecker` with a constant result. - static func producing(eligibility: @autoclosure @escaping () -> IntroEligibilityStatus) -> Self { - return .init { packages in - return Dictionary( - uniqueKeysWithValues: Set(packages) - .map { package in - let result = package.storeProduct.hasIntroDiscount - ? eligibility() - : .noIntroOfferExists - - return (package, result) - } - ) - } - } - - /// Creates a copy of this `TrialOrIntroEligibilityChecker` with a delay. - func with(delay seconds: TimeInterval) -> Self { - return .init { [checker = self.checker] in - await Task.sleep(seconds: seconds) - - return await checker($0) - } - } - -} - -@available(iOS 15.0, macOS 12.0, tvOS 15.0, *) -extension PurchaseHandler { - - static func mock() -> Self { - return self.init { _ in - return ( - transaction: nil, - customerInfo: TestData.customerInfo, - userCancelled: false - ) - } restorePurchases: { - return TestData.customerInfo - } trackEvent: { event in - Logger.debug("Tracking event: \(event)") - } - } - - static func cancelling() -> Self { - return .mock() - .map { block in { - var result = try await block($0) - result.userCancelled = true - return result - } - } restore: { $0 } - } - - /// Creates a copy of this `PurchaseHandler` with a delay. - func with(delay seconds: TimeInterval) -> Self { - return self.map { purchaseBlock in { - await Task.sleep(seconds: seconds) - - return try await purchaseBlock($0) - } - } restore: { restoreBlock in { - await Task.sleep(seconds: seconds) - - return try await restoreBlock() - } - } - } - -} - // MARK: - extension PaywallColor: ExpressibleByStringLiteral { @@ -614,13 +540,4 @@ extension PackageType { } -@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) -private extension Task where Success == Never, Failure == Never { - - static func sleep(seconds: TimeInterval) async { - try? await Self.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) - } - -} - #endif diff --git a/RevenueCatUI/Purchasing/MockPurchases.swift b/RevenueCatUI/Purchasing/MockPurchases.swift new file mode 100644 index 0000000000..f97e156bc2 --- /dev/null +++ b/RevenueCatUI/Purchasing/MockPurchases.swift @@ -0,0 +1,86 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// MockPurchasesType.swift +// +// Created by Nacho Soto on 9/12/23. + +import RevenueCat + +#if DEBUG + +/// An implementation of `PaywallPurchasesType` that allows creating custom blocks. +@available(iOS 15.0, macOS 12.0, tvOS 15.0, *) +final class MockPurchases: PaywallPurchasesType { + + typealias PurchaseBlock = @Sendable (Package) async throws -> PurchaseResultData + typealias RestoreBlock = @Sendable () async throws -> CustomerInfo + typealias TrackEventBlock = @Sendable (PaywallEvent) async -> Void + + private let purchaseBlock: PurchaseBlock + private let restoreBlock: RestoreBlock + private let trackEventBlock: TrackEventBlock + + init( + purchase: @escaping PurchaseBlock, + restorePurchases: @escaping RestoreBlock, + trackEvent: @escaping TrackEventBlock + ) { + self.purchaseBlock = purchase + self.restoreBlock = restorePurchases + self.trackEventBlock = trackEvent + } + + func purchase(package: Package) async throws -> PurchaseResultData { + return try await self.purchaseBlock(package) + } + + func restorePurchases() async throws -> CustomerInfo { + return try await self.restoreBlock() + } + + func track(paywallEvent: PaywallEvent) async { + await self.trackEventBlock(paywallEvent) + } + +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, *) +extension PaywallPurchasesType { + + /// Creates a copy of this `PaywallPurchasesType` wrapping `purchase` and `restore`. + func map( + purchase: @escaping (@escaping MockPurchases.PurchaseBlock) -> MockPurchases.PurchaseBlock, + restore: @escaping (@escaping MockPurchases.RestoreBlock) -> MockPurchases.RestoreBlock + ) -> PaywallPurchasesType { + return MockPurchases { package in + try await purchase(self.purchase(package:))(package) + } restorePurchases: { + try await restore(self.restorePurchases)() + } trackEvent: { event in + await self.track(paywallEvent: event) + } + } + + /// Creates a copy of this `PaywallPurchasesType` wrapping `trackEvent`. + func map( + trackEvent: @escaping (@escaping MockPurchases.TrackEventBlock) -> MockPurchases.TrackEventBlock + ) -> PaywallPurchasesType { + return MockPurchases { package in + try await self.purchase(package: package) + } restorePurchases: { + try await self.restorePurchases() + } trackEvent: { event in + await trackEvent(self.track(paywallEvent:))(event) + } + } + +} + +#endif diff --git a/RevenueCatUI/Purchasing/PaywallPurchasesType.swift b/RevenueCatUI/Purchasing/PaywallPurchasesType.swift new file mode 100644 index 0000000000..abcbcaaf0e --- /dev/null +++ b/RevenueCatUI/Purchasing/PaywallPurchasesType.swift @@ -0,0 +1,31 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// PaywallPurchasesType.swift +// +// Created by Nacho Soto on 9/12/23. + +import RevenueCat + +/// A simplified protocol for the subset of `PurchasesType` needed for `RevenueCatUI`. +@available(iOS 15.0, macOS 12.0, tvOS 15.0, *) +protocol PaywallPurchasesType: Sendable { + + @Sendable + func purchase(package: Package) async throws -> PurchaseResultData + + @Sendable + func restorePurchases() async throws -> CustomerInfo + + @Sendable + func track(paywallEvent: PaywallEvent) async + +} + +extension Purchases: PaywallPurchasesType {} diff --git a/RevenueCatUI/Purchasing/PurchaseHandler+TestData.swift b/RevenueCatUI/Purchasing/PurchaseHandler+TestData.swift new file mode 100644 index 0000000000..3dc0bc3d02 --- /dev/null +++ b/RevenueCatUI/Purchasing/PurchaseHandler+TestData.swift @@ -0,0 +1,74 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// PurchaseHandler+TestData.swift +// +// Created by Nacho Soto on 9/12/23. + +import Foundation +import RevenueCat + +#if DEBUG + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, *) +extension PurchaseHandler { + + static func mock() -> Self { + return self.init( + purchases: MockPurchases { _ in + return ( + transaction: nil, + customerInfo: TestData.customerInfo, + userCancelled: false + ) + } restorePurchases: { + return TestData.customerInfo + } trackEvent: { event in + Logger.debug("Tracking event: \(event)") + } + ) + } + + static func cancelling() -> Self { + return .mock() + .map { block in { + var result = try await block($0) + result.userCancelled = true + return result + } + } restore: { $0 } + } + + /// Creates a copy of this `PurchaseHandler` with a delay. + func with(delay seconds: TimeInterval) -> Self { + return self.map { purchaseBlock in { + await Task.sleep(seconds: seconds) + + return try await purchaseBlock($0) + } + } restore: { restoreBlock in { + await Task.sleep(seconds: seconds) + + return try await restoreBlock() + } + } + } + +} + +@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) +extension Task where Success == Never, Failure == Never { + + static func sleep(seconds: TimeInterval) async { + try? await Self.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + } + +} + +#endif diff --git a/RevenueCatUI/Purchasing/PurchaseHandler.swift b/RevenueCatUI/Purchasing/PurchaseHandler.swift index 3021f12fd9..64929bffa9 100644 --- a/RevenueCatUI/Purchasing/PurchaseHandler.swift +++ b/RevenueCatUI/Purchasing/PurchaseHandler.swift @@ -18,17 +18,11 @@ import SwiftUI @available(iOS 15.0, macOS 12.0, tvOS 15.0, *) final class PurchaseHandler: ObservableObject { - typealias PurchaseBlock = @Sendable (Package) async throws -> PurchaseResultData - typealias RestoreBlock = @Sendable () async throws -> CustomerInfo - typealias TrackEventBlock = @Sendable (PaywallEvent) async -> Void + private let purchases: PaywallPurchasesType /// `false` if this `PurchaseHandler` is not backend by a configured `Purchases`instance. let isConfigured: Bool - private let purchaseBlock: PurchaseBlock - private let restoreBlock: RestoreBlock - private let trackEventBlock: TrackEventBlock - /// Whether a purchase or restore is currently in progress @Published fileprivate(set) var actionInProgress: Bool = false @@ -52,37 +46,23 @@ final class PurchaseHandler: ObservableObject { private var eventData: PaywallEvent.Data? convenience init(purchases: Purchases = .shared) { - self.init(isConfigured: true) { package in - return try await purchases.purchase(package: package) - } restorePurchases: { - return try await purchases.restorePurchases() - } trackEvent: { event in - await purchases.track(paywallEvent: event) - } + self.init(isConfigured: true, purchases: purchases) } init( isConfigured: Bool = true, - purchase: @escaping PurchaseBlock, - restorePurchases: @escaping RestoreBlock, - trackEvent: @escaping TrackEventBlock + purchases: PaywallPurchasesType ) { self.isConfigured = isConfigured - self.purchaseBlock = purchase - self.restoreBlock = restorePurchases - self.trackEventBlock = trackEvent + self.purchases = purchases } static func `default`() -> Self { - return Purchases.isConfigured ? .init() : .notConfigured() + return Purchases.isConfigured ? .init() : Self.notConfigured() } private static func notConfigured() -> Self { - return .init(isConfigured: false) { _ in - throw ErrorCode.configurationError - } restorePurchases: { - throw ErrorCode.configurationError - } trackEvent: { _ in } + return .init(isConfigured: false, purchases: NotConfiguredPurchases()) } } @@ -97,7 +77,7 @@ extension PurchaseHandler { } defer { self.actionInProgress = false } - let result = try await self.purchaseBlock(package) + let result = try await self.purchases.purchase(package: package) if result.userCancelled { self.trackCancelledPurchase() @@ -116,7 +96,7 @@ extension PurchaseHandler { self.actionInProgress = true defer { self.actionInProgress = false } - let customerInfo = try await self.restoreBlock() + let customerInfo = try await self.purchases.restorePurchases() withAnimation(Constants.defaultAnimation) { self.restored = true @@ -146,44 +126,63 @@ extension PurchaseHandler { } +#if DEBUG + @available(iOS 15.0, macOS 12.0, tvOS 15.0, *) extension PurchaseHandler { /// Creates a copy of this `PurchaseHandler` wrapping the purchase and restore blocks. func map( - purchase: @escaping (@escaping PurchaseBlock) -> PurchaseBlock, - restore: @escaping (@escaping RestoreBlock) -> RestoreBlock + purchase: @escaping (@escaping MockPurchases.PurchaseBlock) -> MockPurchases.PurchaseBlock, + restore: @escaping (@escaping MockPurchases.RestoreBlock) -> MockPurchases.RestoreBlock ) -> Self { - return .init(purchase: purchase(self.purchaseBlock), - restorePurchases: restore(self.restoreBlock), - trackEvent: self.trackEventBlock) + return .init( + isConfigured: self.isConfigured, + purchases: self.purchases.map(purchase: purchase, restore: restore) + ) } func map( - trackEvent: @escaping (@escaping TrackEventBlock) -> TrackEventBlock + trackEvent: @escaping (@escaping MockPurchases.TrackEventBlock) -> MockPurchases.TrackEventBlock ) -> Self { return .init( - purchase: self.purchaseBlock, - restorePurchases: self.restoreBlock, - trackEvent: trackEvent(self.trackEventBlock) + isConfigured: self.isConfigured, + purchases: self.purchases.map(trackEvent: trackEvent) ) } } +#endif + // MARK: - Private @available(iOS 15.0, macOS 12.0, tvOS 15.0, *) private extension PurchaseHandler { func track(_ event: PaywallEvent) { - Task.detached(priority: .background) { [block = self.trackEventBlock] in - await block(event) + Task.detached(priority: .background) { [purchases = self.purchases] in + await purchases.track(paywallEvent: event) } } } +@available(iOS 15.0, macOS 12.0, tvOS 15.0, *) +private final class NotConfiguredPurchases: PaywallPurchasesType { + + func purchase(package: Package) async throws -> PurchaseResultData { + throw ErrorCode.configurationError + } + + func restorePurchases() async throws -> CustomerInfo { + throw ErrorCode.configurationError + } + + func track(paywallEvent: PaywallEvent) async {} + +} + // MARK: - Preference Keys @available(iOS 15.0, macOS 12.0, tvOS 15.0, *) diff --git a/RevenueCatUI/Views/LoadingPaywallView.swift b/RevenueCatUI/Views/LoadingPaywallView.swift index b7e444ecca..b64249b072 100644 --- a/RevenueCatUI/Views/LoadingPaywallView.swift +++ b/RevenueCatUI/Views/LoadingPaywallView.swift @@ -65,17 +65,7 @@ private extension LoadingPaywallView { uniqueKeysWithValues: packages.map { ($0, .unknown) } ) }) - static let purchaseHandler: PurchaseHandler = .init( - purchase: { _ in - fatalError("Should not be able to purchase") - }, - restorePurchases: { - fatalError("Should not be able to purchase") - }, - trackEvent: { _ in - // Ignoring events from loading paywall view - } - ) + static let purchaseHandler: PurchaseHandler = .init(purchases: LoadingPaywallPurchases()) static let offeringIdentifier = "offering" static let weeklyPackage = Package( @@ -128,6 +118,23 @@ private extension LoadingPaywallView { ) } +@available(iOS 15.0, macOS 12.0, tvOS 15.0, *) +private final class LoadingPaywallPurchases: PaywallPurchasesType { + + func purchase(package: Package) async throws -> PurchaseResultData { + fatalError("Should not be able to purchase") + } + + func restorePurchases() async throws -> CustomerInfo { + fatalError("Should not be able to purchase") + } + + func track(paywallEvent: PaywallEvent) async { + // Ignoring events from loading paywall view + } + +} + // MARK: - #if DEBUG