Skip to content

Commit

Permalink
[CustomerCenter] Add action handler (#4057)
Browse files Browse the repository at this point in the history
### Description
This PR provides a possible approach to implementing an "action"
handler. This allows developer to respond to events that happen during
the customer support flow.

The current approach consists of making `CustomerCenterActionHandler`, a
lambda that receives an action that can be passed in by the developer.
Then calling that with the appropriate action from the customer center.
This PR also moves some code to the view model for simplicity and moving
logic away from the view layer.
  • Loading branch information
tonidero authored Jul 18, 2024
1 parent 1868044 commit 2a9cd2c
Show file tree
Hide file tree
Showing 11 changed files with 164 additions and 55 deletions.
4 changes: 4 additions & 0 deletions RevenueCat.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
1E473B682AC43254008B07F9 /* StoreMessageType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E473B672AC43254008B07F9 /* StoreMessageType.swift */; };
1E568B512ACC6A8300D3C12F /* StoreMessageTypeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E568B502ACC6A8300D3C12F /* StoreMessageTypeTests.swift */; };
1E5F8F6E2C4515430041EECD /* View+PresentCustomerCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E5F8F6D2C4515430041EECD /* View+PresentCustomerCenter.swift */; };
1E5F8F782C46BBD90041EECD /* CustomerCenterAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E5F8F772C46BBD90041EECD /* CustomerCenterAction.swift */; };
1E99F81F2AC5917F0023E26E /* StoreMessagesHelperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E99F81D2AC5917F0023E26E /* StoreMessagesHelperTests.swift */; };
2C0B98CD2797070B00C5874F /* PromotionalOffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C0B98CC2797070B00C5874F /* PromotionalOffer.swift */; };
2C6CC1162B8D2B6900432E4D /* PurchasesSyncAttributesAndOfferingsIfNeededTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C6CC1152B8D2B6800432E4D /* PurchasesSyncAttributesAndOfferingsIfNeededTests.swift */; };
Expand Down Expand Up @@ -1049,6 +1050,7 @@
1E473B692AC46908008B07F9 /* MockStoreMessagesHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockStoreMessagesHelper.swift; sourceTree = "<group>"; };
1E568B502ACC6A8300D3C12F /* StoreMessageTypeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreMessageTypeTests.swift; sourceTree = "<group>"; };
1E5F8F6D2C4515430041EECD /* View+PresentCustomerCenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+PresentCustomerCenter.swift"; sourceTree = "<group>"; };
1E5F8F772C46BBD90041EECD /* CustomerCenterAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerCenterAction.swift; sourceTree = "<group>"; };
1E99F81D2AC5917F0023E26E /* StoreMessagesHelperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreMessagesHelperTests.swift; sourceTree = "<group>"; };
2C0B98CC2797070B00C5874F /* PromotionalOffer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromotionalOffer.swift; sourceTree = "<group>"; };
2C646C282A0EBD0300E5936E /* CI-Snapshots.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; name = "CI-Snapshots.xctestplan"; path = "Tests/TestPlans/CI-Snapshots.xctestplan"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -2557,6 +2559,7 @@
353756542C382C2800A1B8D6 /* CustomerCenterError.swift */,
35C200AE2C39252D00B9778B /* FeedbackSurveyData.swift */,
353756552C382C2800A1B8D6 /* SubscriptionInformation.swift */,
1E5F8F772C46BBD90041EECD /* CustomerCenterAction.swift */,
);
path = Data;
sourceTree = "<group>";
Expand Down Expand Up @@ -5162,6 +5165,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
1E5F8F782C46BBD90041EECD /* CustomerCenterAction.swift in Sources */,
887A60CC2C1D037000E1A461 /* PaywallFontProvider.swift in Sources */,
887A60B82C1D037000E1A461 /* Template1View.swift in Sources */,
887A60C62C1D037000E1A461 /* LoadingPaywallView.swift in Sources */,
Expand Down
22 changes: 22 additions & 0 deletions RevenueCatUI/CustomerCenter/Data/CustomerCenterAction.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import RevenueCat

/// Typealias for handler for Customer center actions
public typealias CustomerCenterActionHandler = @MainActor @Sendable (CustomerCenterAction) -> Void

/// Represents an event the customer may perform during the Customer Center flow
public enum CustomerCenterAction {

/// Starting the restoration process
case restoreStarted
/// Restore errored out
case restoreFailed(_ error: Error)
/// Restore completed successfully
case restoreCompleted(_ customerInfo: CustomerInfo)
/// Going to display manage subscription page, whether for cancellation or changing plans.
case showingManageSubscriptions
/// Starting refund request process
case refundRequestStarted(_ productId: String)
/// Refund request process finished, with result provided.
case refundRequestCompleted(_ refundRequestStatus: RefundRequestStatus)

}
16 changes: 11 additions & 5 deletions RevenueCatUI/CustomerCenter/View+PresentCustomerCenter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,16 +52,19 @@ extension View {
/// - Parameter isPresented: Binding indicating whether the customer center should be displayed
/// - Parameter onDismiss: Callback executed when the customer center wants to be dismissed.
/// Make sure you stop presenting the customer center when this is called
/// - Parameter customerCenterActionHandler: Allows to listen to certain events during the customer center flow.
/// - Parameter presentationMode: The desired presentation mode of the customer center. Defaults to `.sheet`.
public func presentCustomerCenter(
isPresented: Binding<Bool>,
onDismiss: @escaping () -> Void,
presentationMode: CustomerCenterPresentationMode = .default
customerCenterActionHandler: CustomerCenterActionHandler? = nil,
presentationMode: CustomerCenterPresentationMode = .default,
onDismiss: @escaping () -> Void
) -> some View {
return self.modifier(PresentingCustomerCenterModifier(
isPresented: isPresented,
onDismiss: onDismiss,
myAppPurchaseLogic: nil,
customerCenterActionHandler: customerCenterActionHandler,
presentationMode: presentationMode
))
}
Expand All @@ -75,19 +78,22 @@ extension View {
@available(visionOS, unavailable)
private struct PresentingCustomerCenterModifier: ViewModifier {

var presentationMode: CustomerCenterPresentationMode
var onDismiss: (() -> Void)
let customerCenterActionHandler: CustomerCenterActionHandler?
let presentationMode: CustomerCenterPresentationMode
let onDismiss: (() -> Void)

init(
isPresented: Binding<Bool>,
onDismiss: @escaping () -> Void,
myAppPurchaseLogic: MyAppPurchaseLogic?,
customerCenterActionHandler: CustomerCenterActionHandler?,
presentationMode: CustomerCenterPresentationMode,
purchaseHandler: PurchaseHandler? = nil
) {
self._isPresented = isPresented
self.presentationMode = presentationMode
self.onDismiss = onDismiss
self.customerCenterActionHandler = customerCenterActionHandler
self._purchaseHandler = .init(wrappedValue: purchaseHandler ??
PurchaseHandler.default(performPurchase: myAppPurchaseLogic?.performPurchase,
performRestore: myAppPurchaseLogic?.performRestore))
Expand Down Expand Up @@ -117,7 +123,7 @@ private struct PresentingCustomerCenterModifier: ViewModifier {
}

private func customerCenterView() -> some View {
CustomerCenterView()
CustomerCenterView(customerCenterActionHandler: self.customerCenterActionHandler)
.interactiveDismissDisabled(self.purchaseHandler.actionInProgress)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
import Foundation
import RevenueCat

#if os(iOS)

@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
@available(macOS, unavailable)
@available(tvOS, unavailable)
Expand Down Expand Up @@ -46,11 +48,13 @@ import RevenueCat
}

private var customerInfoFetcher: CustomerInfoFetcher
internal let customerCenterActionHandler: CustomerCenterActionHandler?

private var error: Error?

convenience init() {
self.init(customerInfoFetcher: {
convenience init(customerCenterActionHandler: CustomerCenterActionHandler?) {
self.init(customerCenterActionHandler: customerCenterActionHandler,
customerInfoFetcher: {
guard Purchases.isConfigured else {
throw PaywallError.purchasesNotConfigured
}
Expand All @@ -59,14 +63,17 @@ import RevenueCat
})
}

// @PublicForExternalTesting
init(customerInfoFetcher: @escaping CustomerInfoFetcher) {
init(customerCenterActionHandler: CustomerCenterActionHandler?,
customerInfoFetcher: @escaping CustomerInfoFetcher) {
self.state = .notLoaded
self.customerInfoFetcher = customerInfoFetcher
self.customerCenterActionHandler = customerCenterActionHandler
}

// @PublicForExternalTesting
init(hasSubscriptions: Bool = false, areSubscriptionsFromApple: Bool = false) {
#if DEBUG

init(hasSubscriptions: Bool = false,
areSubscriptionsFromApple: Bool = false) {
self.hasSubscriptions = hasSubscriptions
self.subscriptionsAreFromApple = areSubscriptionsFromApple
self.customerInfoFetcher = {
Expand All @@ -77,8 +84,11 @@ import RevenueCat
return try await Purchases.shared.customerInfo()
}
self.state = .success
self.customerCenterActionHandler = nil
}

#endif

func loadHasSubscriptions() async {
do {
// swiftlint:disable:next todo
Expand Down Expand Up @@ -107,4 +117,19 @@ import RevenueCat
}
}

func performRestore() async -> RestorePurchasesAlert.AlertType {
self.customerCenterActionHandler?(.restoreStarted)
do {
let customerInfo = try await Purchases.shared.restorePurchases()
self.customerCenterActionHandler?(.restoreCompleted(customerInfo))
let hasEntitlements = customerInfo.entitlements.active.count > 0
return hasEntitlements ? .purchasesRecovered : .purchasesNotFound
} catch {
self.customerCenterActionHandler?(.restoreFailed(error))
return .purchasesNotFound
}
}

}

#endif
Original file line number Diff line number Diff line change
Expand Up @@ -49,29 +49,36 @@ class ManageSubscriptionsViewModel: ObservableObject {
@Published
private(set) var refundRequestStatusMessage: String?

private var purchasesProvider: ManageSubscriptionsPurchaseType
private let purchasesProvider: ManageSubscriptionsPurchaseType
private let customerCenterActionHandler: CustomerCenterActionHandler?

private var error: Error?

convenience init(screen: CustomerCenterConfigData.Screen) {
convenience init(screen: CustomerCenterConfigData.Screen,
customerCenterActionHandler: CustomerCenterActionHandler?) {
self.init(screen: screen,
purchasesProvider: ManageSubscriptionPurchases())
purchasesProvider: ManageSubscriptionPurchases(),
customerCenterActionHandler: customerCenterActionHandler)
}

init(screen: CustomerCenterConfigData.Screen,
purchasesProvider: ManageSubscriptionsPurchaseType) {
purchasesProvider: ManageSubscriptionsPurchaseType,
customerCenterActionHandler: CustomerCenterActionHandler?) {
self.state = .notLoaded
self.screen = screen
self.purchasesProvider = purchasesProvider
self.customerCenterActionHandler = customerCenterActionHandler
}

init(screen: CustomerCenterConfigData.Screen,
subscriptionInformation: SubscriptionInformation,
customerCenterActionHandler: CustomerCenterActionHandler?,
refundRequestStatusMessage: String? = nil) {
self.screen = screen
self.subscriptionInformation = subscriptionInformation
self.purchasesProvider = ManageSubscriptionPurchases()
self.refundRequestStatusMessage = refundRequestStatusMessage
self.customerCenterActionHandler = customerCenterActionHandler
state = .success
}

Expand Down Expand Up @@ -141,7 +148,9 @@ class ManageSubscriptionsViewModel: ObservableObject {
do {
guard let subscriptionInformation = self.subscriptionInformation else { return }
let productId = subscriptionInformation.productIdentifier
let status = try await purchasesProvider.beginRefundRequest(forProduct: productId)
self.customerCenterActionHandler?(.refundRequestStarted(productId))
let status = try await self.purchasesProvider.beginRefundRequest(forProduct: productId)
self.customerCenterActionHandler?(.refundRequestCompleted(status))
switch status {
case .error:
self.refundRequestStatusMessage = String(localized: "Error when requesting refund, try again")
Expand All @@ -151,11 +160,13 @@ class ManageSubscriptionsViewModel: ObservableObject {
self.refundRequestStatusMessage = String(localized: "Refund canceled")
}
} catch {
self.customerCenterActionHandler?(.refundRequestCompleted(.error))
self.refundRequestStatusMessage =
String(localized: "An error occurred while processing the refund request.")
}
case .changePlans, .cancel:
do {
self.customerCenterActionHandler?(.showingManageSubscriptions)
try await purchasesProvider.showManageSubscriptions()
} catch {
self.state = .error(error)
Expand Down
15 changes: 10 additions & 5 deletions RevenueCatUI/CustomerCenter/Views/CustomerCenterView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,13 @@ import SwiftUI
@available(visionOS, unavailable)
public struct CustomerCenterView: View {

@StateObject private var viewModel = CustomerCenterViewModel()
@StateObject private var viewModel: CustomerCenterViewModel

/// Create a view to handle common customer support tasks
public init() {}
public init(customerCenterActionHandler: CustomerCenterActionHandler? = nil) {
self._viewModel = .init(wrappedValue:
CustomerCenterViewModel(customerCenterActionHandler: customerCenterActionHandler))
}

fileprivate init(viewModel: CustomerCenterViewModel) {
self._viewModel = .init(wrappedValue: viewModel)
Expand All @@ -38,17 +41,18 @@ public struct CustomerCenterView: View {
// swiftlint:disable:next missing_docs
public var body: some View {
Group {
if !viewModel.isLoaded {
if !self.viewModel.isLoaded {
ProgressView()
} else {
if let configuration = viewModel.configuration {
if let configuration = self.viewModel.configuration {
destinationView(configuration: configuration)
}
}
}
.task {
await loadInformationIfNeeded()
}
.environmentObject(self.viewModel)
}

}
Expand All @@ -72,7 +76,8 @@ private extension CustomerCenterView {
if viewModel.hasSubscriptions {
if viewModel.subscriptionsAreFromApple,
let screen = configuration.screens[.management] {
ManageSubscriptionsView(screen: screen)
ManageSubscriptionsView(screen: screen,
customerCenterActionHandler: viewModel.customerCenterActionHandler)
} else {
WrongPlatformView()
}
Expand Down
10 changes: 7 additions & 3 deletions RevenueCatUI/CustomerCenter/Views/ManageSubscriptionsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,10 @@ struct ManageSubscriptionsView: View {
@StateObject
private var viewModel: ManageSubscriptionsViewModel

init(screen: CustomerCenterConfigData.Screen) {
let viewModel = ManageSubscriptionsViewModel(screen: screen)
init(screen: CustomerCenterConfigData.Screen,
customerCenterActionHandler: CustomerCenterActionHandler?) {
let viewModel = ManageSubscriptionsViewModel(screen: screen,
customerCenterActionHandler: customerCenterActionHandler)
self._viewModel = .init(wrappedValue: viewModel)
}

Expand Down Expand Up @@ -262,13 +264,15 @@ struct ManageSubscriptionsView_Previews: PreviewProvider {
let viewModelMonthlyRenewing = ManageSubscriptionsViewModel(
screen: CustomerCenterConfigTestData.customerCenterData.screens[.management]!,
subscriptionInformation: CustomerCenterConfigTestData.subscriptionInformationMonthlyRenewing,
customerCenterActionHandler: nil,
refundRequestStatusMessage: "Refund granted successfully!")
ManageSubscriptionsView(viewModel: viewModelMonthlyRenewing)
.previewDisplayName("Monthly renewing")

let viewModelYearlyExpiring = ManageSubscriptionsViewModel(
screen: CustomerCenterConfigTestData.customerCenterData.screens[.management]!,
subscriptionInformation: CustomerCenterConfigTestData.subscriptionInformationYearlyExpiring)
subscriptionInformation: CustomerCenterConfigTestData.subscriptionInformationYearlyExpiring,
customerCenterActionHandler: nil)

ManageSubscriptionsView(viewModel: viewModelYearlyExpiring)
.previewDisplayName("Yearly expiring")
Expand Down
15 changes: 4 additions & 11 deletions RevenueCatUI/CustomerCenter/Views/RestorePurchasesAlert.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ struct RestorePurchasesAlert: ViewModifier {
@Environment(\.openURL)
var openURL

@EnvironmentObject private var customerCenterViewModel: CustomerCenterViewModel

@State
private var alertType: AlertType = .restorePurchases
@Environment(\.dismiss)
Expand All @@ -54,17 +56,8 @@ struct RestorePurchasesAlert: ViewModifier {
"""),
primaryButton: .default(Text("Check past purchases"), action: {
Task {
guard let customerInfo = try? await Purchases.shared.restorePurchases() else {
// todo: handle errors
self.setAlertType(.purchasesNotFound)
return
}
let hasEntitlements = customerInfo.entitlements.active.count > 0
if hasEntitlements {
self.setAlertType(.purchasesRecovered)
} else {
self.setAlertType(.purchasesNotFound)
}
let alertType = await self.customerCenterViewModel.performRestore()
self.setAlertType(alertType)
}
}),
secondaryButton: .cancel(Text("Cancel"))
Expand Down
Loading

0 comments on commit 2a9cd2c

Please sign in to comment.