diff --git a/RevenueCatUI/Data/Constants.swift b/RevenueCatUI/Data/Constants.swift index c13ea6732e..4c21bc5b62 100644 --- a/RevenueCatUI/Data/Constants.swift +++ b/RevenueCatUI/Data/Constants.swift @@ -11,5 +11,6 @@ import SwiftUI enum Constants { static let defaultAnimation: Animation = .easeIn(duration: 0.2) + static let fastAnimation: Animation = .easeIn(duration: 0.1) } diff --git a/RevenueCatUI/PaywallView.swift b/RevenueCatUI/PaywallView.swift index d21c047c90..b3bb877c82 100644 --- a/RevenueCatUI/PaywallView.swift +++ b/RevenueCatUI/PaywallView.swift @@ -6,6 +6,7 @@ import SwiftUI @available(watchOS, unavailable, message: "RevenueCatUI does not support watchOS yet") @available(macOS, unavailable, message: "RevenueCatUI does not support macOS yet") @available(macCatalyst, unavailable, message: "RevenueCatUI does not support Catalyst yet") +@MainActor public struct PaywallView: View { private let mode: PaywallViewMode @@ -22,6 +23,7 @@ public struct PaywallView: View { /// an error will be displayed. /// - Warning: `Purchases` must have been configured prior to displaying it. /// If you want to handle that, you can use ``init(offering:mode:)`` instead. + @MainActor public init(mode: PaywallViewMode = .default) { self.init( offering: nil, @@ -35,6 +37,7 @@ public struct PaywallView: View { /// - Note: if `offering` does not have a current paywall, or it fails to load due to invalid data, /// a default paywall will be displayed. /// - Warning: `Purchases` must have been configured prior to displaying it. + @MainActor public init(offering: Offering, mode: PaywallViewMode = .default) { self.init( offering: offering, @@ -44,6 +47,7 @@ public struct PaywallView: View { ) } + @MainActor init( offering: Offering?, mode: PaywallViewMode = .default, @@ -158,7 +162,7 @@ struct LoadedOfferingPaywallView: View { self._introEligibility = .init( wrappedValue: .init(introEligibilityChecker: introEligibility) ) - self.purchaseHandler = purchaseHandler + self._purchaseHandler = .init(initialValue: purchaseHandler) } var body: some View { @@ -170,6 +174,7 @@ struct LoadedOfferingPaywallView: View { .environmentObject(self.introEligibility) .environmentObject(self.purchaseHandler) .hidden(if: self.shouldHidePaywall) + .disabled(self.purchaseHandler.actionInProgress) if let aspectRatio = self.mode.aspectRatio { view.aspectRatio(aspectRatio, contentMode: .fit) diff --git a/RevenueCatUI/Purchasing/PurchaseHandler.swift b/RevenueCatUI/Purchasing/PurchaseHandler.swift index 6bda2c3ee1..91d5c74949 100644 --- a/RevenueCatUI/Purchasing/PurchaseHandler.swift +++ b/RevenueCatUI/Purchasing/PurchaseHandler.swift @@ -10,6 +10,7 @@ import StoreKit import SwiftUI @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.2, *) +@MainActor final class PurchaseHandler: ObservableObject { typealias PurchaseBlock = @Sendable (Package) async throws -> PurchaseResultData @@ -18,8 +19,17 @@ final class PurchaseHandler: ObservableObject { private let purchaseBlock: PurchaseBlock private let restoreBlock: RestoreBlock + /// Whether a purchase or restore is currently in progress @Published - var purchased: Bool = false + fileprivate(set) var actionInProgress: Bool = false + + /// Whether a purchase was successfully completed. + @Published + fileprivate(set) var purchased: Bool = false + + /// Whether a restore was successfully completed. + @Published + fileprivate(set) var restored: Bool = false convenience init(purchases: Purchases = .shared) { self.init { package in @@ -43,6 +53,11 @@ final class PurchaseHandler: ObservableObject { extension PurchaseHandler { func purchase(package: Package) async throws -> PurchaseResultData { + withAnimation(Constants.fastAnimation) { + self.actionInProgress = true + } + defer { self.actionInProgress = false } + let result = try await self.purchaseBlock(package) if !result.userCancelled { @@ -55,7 +70,14 @@ extension PurchaseHandler { } func restorePurchases() async throws -> CustomerInfo { - return try await self.restoreBlock() + self.actionInProgress = true + defer { self.actionInProgress = false } + + let result = try await self.restoreBlock() + + self.restored = true + + return result } /// Creates a copy of this `PurchaseHandler` wrapping the purchase and restore blocks. diff --git a/RevenueCatUI/Templates/MultiPackageBoldTemplate.swift b/RevenueCatUI/Templates/MultiPackageBoldTemplate.swift index 15fd42380c..78fcd00e89 100644 --- a/RevenueCatUI/Templates/MultiPackageBoldTemplate.swift +++ b/RevenueCatUI/Templates/MultiPackageBoldTemplate.swift @@ -5,38 +5,20 @@ import SwiftUI struct MultiPackageBoldTemplate: TemplateViewType { private let configuration: TemplateViewConfiguration - @EnvironmentObject - private var introEligibility: IntroEligibilityViewModel - - init(_ configuration: TemplateViewConfiguration) { - self.configuration = configuration - } - - var body: some View { - MultiPackageTemplateContent(configuration: self.configuration, - introEligibility: self.introEligibility.allEligibility) - } - -} - -@available(iOS 16.0, macOS 13.0, tvOS 16.0, *) -private struct MultiPackageTemplateContent: View { - - private var configuration: TemplateViewConfiguration - private var introEligibility: [Package: IntroEligibilityStatus] private var localization: [Package: ProcessedLocalizedConfiguration] @State private var selectedPackage: Package + @EnvironmentObject + private var introEligibilityViewModel: IntroEligibilityViewModel @EnvironmentObject private var purchaseHandler: PurchaseHandler - init(configuration: TemplateViewConfiguration, introEligibility: [Package: IntroEligibilityStatus]) { + init(_ configuration: TemplateViewConfiguration) { self._selectedPackage = .init(initialValue: configuration.packages.default.content) self.configuration = configuration - self.introEligibility = introEligibility self.localization = Dictionary( uniqueKeysWithValues: configuration.packages.all .lazy @@ -72,7 +54,7 @@ private struct MultiPackageTemplateContent: View { purchaseHandler: self.purchaseHandler) } } - .animation(.easeInOut(duration: 0.1), value: self.selectedPackage) + .animation(Constants.fastAnimation, value: self.selectedPackage) .frame(maxHeight: .infinity) .multilineTextAlignment(.center) .frame(maxHeight: .infinity) @@ -105,13 +87,15 @@ private struct MultiPackageTemplateContent: View { private var packages: some View { VStack(spacing: 8) { ForEach(self.configuration.packages.all, id: \.content.id) { package in + let isSelected = self.selectedPackage === package.content + Button { self.selectedPackage = package.content } label: { - self.packageButton(package, selected: self.selectedPackage === package.content) + self.packageButton(package, selected: isSelected) .contentShape(Rectangle()) } - .buttonStyle(PackageButtonStyle()) + .buttonStyle(PackageButtonStyle(isSelected: isSelected)) } } .padding(.bottom) @@ -175,11 +159,11 @@ private struct MultiPackageTemplateContent: View { private var subscribeButton: some View { PurchaseButton( package: self.selectedPackage, - purchaseHandler: self.purchaseHandler, colors: self.configuration.colors, localization: self.selectedLocalization, introEligibility: self.introEligibility[self.selectedPackage], - mode: self.configuration.mode + mode: self.configuration.mode, + purchaseHandler: self.purchaseHandler ) } @@ -214,6 +198,12 @@ private struct MultiPackageTemplateContent: View { .padding(.top) } + // MARK: - + + private var introEligibility: [Package: IntroEligibilityStatus] { + return self.introEligibilityViewModel.allEligibility + } + private var selectedBackgroundColor: Color { self.configuration.colors.accent2Color } private static let iconSize: CGFloat = 100 @@ -224,7 +214,7 @@ private struct MultiPackageTemplateContent: View { // MARK: - Extensions @available(iOS 16.0, macOS 13.0, tvOS 16.0, *) -private extension MultiPackageTemplateContent { +private extension MultiPackageBoldTemplate { func localization(for package: Package) -> ProcessedLocalizedConfiguration { // Because of how packages are constructed this is known to exist @@ -236,12 +226,3 @@ private extension MultiPackageTemplateContent { } } - -@available(iOS 16.0, macOS 13.0, tvOS 16.0, *) -private struct PackageButtonStyle: ButtonStyle { - - func makeBody(configuration: ButtonStyleConfiguration) -> some View { - configuration.label - } - -} diff --git a/RevenueCatUI/Templates/OnePackageStandardTemplate.swift b/RevenueCatUI/Templates/OnePackageStandardTemplate.swift index 4efe81fc52..db6269de0b 100644 --- a/RevenueCatUI/Templates/OnePackageStandardTemplate.swift +++ b/RevenueCatUI/Templates/OnePackageStandardTemplate.swift @@ -5,33 +5,15 @@ import SwiftUI struct OnePackageStandardTemplate: TemplateViewType { private let configuration: TemplateViewConfiguration - @EnvironmentObject - private var introEligibility: IntroEligibilityViewModel - - init(_ configuration: TemplateViewConfiguration) { - self.configuration = configuration - } - - var body: some View { - OnePackageTemplateContent(configuration: self.configuration, - introEligibility: self.introEligibility.singleEligibility) - } - -} - -@available(iOS 16.0, macOS 13.0, tvOS 16.0, *) -private struct OnePackageTemplateContent: View { - - private var configuration: TemplateViewConfiguration - private var introEligibility: IntroEligibilityStatus? private var localization: ProcessedLocalizedConfiguration + @EnvironmentObject + private var introEligibilityViewModel: IntroEligibilityViewModel @EnvironmentObject private var purchaseHandler: PurchaseHandler - init(configuration: TemplateViewConfiguration, introEligibility: IntroEligibilityStatus?) { + init(_ configuration: TemplateViewConfiguration) { self.configuration = configuration - self.introEligibility = introEligibility self.localization = configuration.packages.single.localization } @@ -128,14 +110,20 @@ private struct OnePackageTemplateContent: View { private var button: some View { PurchaseButton( package: self.configuration.packages.single.content, - purchaseHandler: self.purchaseHandler, colors: self.configuration.colors, localization: self.localization, introEligibility: self.introEligibility, - mode: self.configuration.mode + mode: self.configuration.mode, + purchaseHandler: self.purchaseHandler ) } + // MARK: - + + private var introEligibility: IntroEligibilityStatus? { + return self.introEligibilityViewModel.singleEligibility + } + private static let imageAspectRatio = 1.1 } diff --git a/RevenueCatUI/Templates/OnePackageWithFeaturesTemplate.swift b/RevenueCatUI/Templates/OnePackageWithFeaturesTemplate.swift index 4fd56b8d52..39b31e9a03 100644 --- a/RevenueCatUI/Templates/OnePackageWithFeaturesTemplate.swift +++ b/RevenueCatUI/Templates/OnePackageWithFeaturesTemplate.swift @@ -12,35 +12,15 @@ import SwiftUI struct OnePackageWithFeaturesTemplate: TemplateViewType { private let configuration: TemplateViewConfiguration - @EnvironmentObject - private var introEligibility: IntroEligibilityViewModel - - init(_ configuration: TemplateViewConfiguration) { - self.configuration = configuration - } - - var body: some View { - OnePackageWithFeaturesTemplateContent( - configuration: self.configuration, - introEligibility: self.introEligibility.singleEligibility - ) - } - -} - -@available(iOS 16.0, macOS 13.0, tvOS 16.0, *) -private struct OnePackageWithFeaturesTemplateContent: View { - - private var configuration: TemplateViewConfiguration - private var introEligibility: IntroEligibilityStatus? - private var localization: ProcessedLocalizedConfiguration + private let localization: ProcessedLocalizedConfiguration + @EnvironmentObject + private var introEligibilityViewModel: IntroEligibilityViewModel @EnvironmentObject private var purchaseHandler: PurchaseHandler - init(configuration: TemplateViewConfiguration, introEligibility: IntroEligibilityStatus?) { + init(_ configuration: TemplateViewConfiguration) { self.configuration = configuration - self.introEligibility = introEligibility self.localization = configuration.packages.single.localization } @@ -84,11 +64,11 @@ private struct OnePackageWithFeaturesTemplateContent: View { PurchaseButton( package: self.configuration.packages.single.content, - purchaseHandler: self.purchaseHandler, colors: self.configuration.colors, localization: self.localization, introEligibility: self.introEligibility, - mode: self.configuration.mode + mode: self.configuration.mode, + purchaseHandler: self.purchaseHandler ) .padding(.bottom) @@ -117,6 +97,10 @@ private struct OnePackageWithFeaturesTemplateContent: View { .edgesIgnoringSafeArea(.all) } + private var introEligibility: IntroEligibilityStatus? { + return self.introEligibilityViewModel.singleEligibility + } + @ScaledMetric(relativeTo: .title) private var iconSize = 55 diff --git a/RevenueCatUI/Views/AsyncButton.swift b/RevenueCatUI/Views/AsyncButton.swift index 5a75ce0c3b..18a31a865c 100644 --- a/RevenueCatUI/Views/AsyncButton.swift +++ b/RevenueCatUI/Views/AsyncButton.swift @@ -19,10 +19,10 @@ struct AsyncButton