diff --git a/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Configuration/PONativeAlternativePaymentConfiguration.swift b/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Configuration/PONativeAlternativePaymentConfiguration.swift index b6484d087..0f4a62fcc 100644 --- a/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Configuration/PONativeAlternativePaymentConfiguration.swift +++ b/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Configuration/PONativeAlternativePaymentConfiguration.swift @@ -149,14 +149,22 @@ public struct PONativeAlternativePaymentConfiguration { /// and if it's the only required step, it will complete the flow without starting the bottom sheet. public let enableHeadlessMode: Bool + /// Redirect confirmation button configuration. To remove button use `nil`, this is default behaviour. + /// + /// Displays a confirmation button when the user needs to perform a redirect. The user + /// must press this button to continue. + public let redirectButton: SubmitButton? + public init( callback: POWebAuthenticationCallback? = nil, prefersEphemeralSession: Bool = true, - enableHeadlessMode: Bool = false + enableHeadlessMode: Bool = false, + redirectButton: SubmitButton? = nil ) { self.callback = callback self.prefersEphemeralSession = prefersEphemeralSession self.enableHeadlessMode = enableHeadlessMode + self.redirectButton = redirectButton } } diff --git a/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Interactor/NativeAlternativePaymentDefaultInteractor.swift b/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Interactor/NativeAlternativePaymentDefaultInteractor.swift index d15dcc90c..4bef2da20 100644 --- a/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Interactor/NativeAlternativePaymentDefaultInteractor.swift +++ b/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Interactor/NativeAlternativePaymentDefaultInteractor.swift @@ -142,29 +142,7 @@ final class NativeAlternativePaymentDefaultInteractor: } let task = Task { do { - let didOpenUrl: Bool - switch currentState.redirect.type { - case .deepLink: - didOpenUrl = await openDeepLink(url: currentState.redirect.url) - case .web: - let authenticationRequest = POAlternativePaymentAuthenticationRequest( - url: currentState.redirect.url, - callback: configuration.redirect.callback, - prefersEphemeralSession: configuration.redirect.prefersEphemeralSession - ) - _ = try await alternativePaymentsService.authenticate(request: authenticationRequest) - didOpenUrl = true - default: - throw POFailure(errorDescription: "Unknown redirect type.", code: .Mobile.internal) - } - let response = try await serviceAdapter.continuePayment( - with: .init( - flow: configuration.flow, - redirect: currentState.redirect.confirmationRequired ? .init(success: didOpenUrl) : nil, - localeIdentifier: configuration.localization.localeOverride?.identifier - ) - ) - try await setState(with: response) + try await uncheckedRedirect(to: currentState.redirect) } catch { setFailureState(error: error) } @@ -212,9 +190,7 @@ final class NativeAlternativePaymentDefaultInteractor: private func setState(with response: NativeAlternativePaymentServiceAdapterResponse) async throws { switch response.state { case .nextStepRequired: - if case .starting = state, let redirect = response.redirect, configuration.redirect.enableHeadlessMode { - try await continueStart(withHeadlessRedirect: redirect) - } else if let redirect = response.redirect { + if let redirect = response.redirect { try await setAwaitingRedirectState(response: response, redirect: redirect) } else { try await setStartedState(response: response) @@ -230,38 +206,6 @@ final class NativeAlternativePaymentDefaultInteractor: } } - // MARK: - Starting State - - private func continueStart(withHeadlessRedirect redirect: PONativeAlternativePaymentRedirectV2) async throws { - guard case .starting = state else { - logger.error("Attempted to handle headless redirect while not in starting state. Ignoring.") - return - } - let didOpenUrl: Bool - switch redirect.type { - case .deepLink: - didOpenUrl = await openDeepLink(url: redirect.url) - case .web: - let authenticationRequest = POAlternativePaymentAuthenticationRequest( - url: redirect.url, - callback: configuration.redirect.callback, - prefersEphemeralSession: configuration.redirect.prefersEphemeralSession - ) - _ = try await alternativePaymentsService.authenticate(request: authenticationRequest) - didOpenUrl = true - default: - throw POFailure(errorDescription: "Unknown redirect type.", code: .Mobile.internal) - } - let response = try await serviceAdapter.continuePayment( - with: .init( - flow: configuration.flow, - redirect: redirect.confirmationRequired ? .init(success: didOpenUrl) : nil, - localeIdentifier: configuration.localization.localeOverride?.identifier - ) - ) - try await setState(with: response) - } - // MARK: - Started State private func setStartedState(response: NativeAlternativePaymentServiceAdapterResponse) async throws { @@ -401,49 +345,80 @@ final class NativeAlternativePaymentDefaultInteractor: response: NativeAlternativePaymentServiceAdapterResponse, redirect: PONativeAlternativePaymentRedirectV2 ) async throws { - let paymentMethod = await resolve(paymentMethod: response.paymentMethod) - let elements = try await resolve(elements: response.elements ?? []) + if shouldConfirmRedirect(redirect, in: state) { + let paymentMethod = await resolve(paymentMethod: response.paymentMethod) + let elements = try await resolve(elements: response.elements ?? []) + switch state { + case .starting, .submitting, .redirecting: + break // todo(andrii-vysotskyi): check if more states should be supported + default: + logger.debug("Ignoring attempt to set started state in unsupported state: \(state).") + return + } + let newState = State.AwaitingRedirect( + paymentMethod: paymentMethod, + invoice: response.invoice, + elements: elements, + redirect: redirect, + isCancellable: configuration.cancelButton?.disabledFor.isZero ?? true + ) + sendDidStartEventIfNeeded() + state = .awaitingRedirect(newState) + enableCancellationAfterDelay() + } else { + try await uncheckedRedirect(to: redirect) + } + } + + private func shouldConfirmRedirect( + _ redirect: PONativeAlternativePaymentRedirectV2, in state: NativeAlternativePaymentInteractorState + ) -> Bool { switch state { - case .starting, .submitting, .redirecting: - break // todo(andrii-vysotskyi): check if more states should be supported + case .redirecting: + return false + case .starting where configuration.redirect.enableHeadlessMode: + return false default: - logger.debug("Ignoring attempt to set started state in unsupported state: \(state).") - return + return configuration.redirect.redirectButton != nil } - let newState = State.AwaitingRedirect( - paymentMethod: paymentMethod, - invoice: response.invoice, - elements: elements, - redirect: redirect, - isCancellable: configuration.cancelButton?.disabledFor.isZero ?? true - ) - sendDidStartEventIfNeeded() - state = .awaitingRedirect(newState) - enableCancellationAfterDelay() } - /// Requests state change to redirecting while bypassing actual redirect assuming it was performed elsewhere. - private func setRedirectingState(didOpenUrl: Bool) { - guard case .awaitingRedirect(let currentState) = state else { - logger.debug("Ignoring redirect confirmation in unsupported state \(state).") - return + // MARK: - Redirecting State + + private func uncheckedRedirect(to redirect: PONativeAlternativePaymentRedirectV2) async throws { + let didOpenUrl: Bool + switch redirect.type { + case .deepLink: + didOpenUrl = await openDeepLink(url: redirect.url) + case .web: + let authenticationRequest = POAlternativePaymentAuthenticationRequest( + url: redirect.url, + callback: configuration.redirect.callback, + prefersEphemeralSession: configuration.redirect.prefersEphemeralSession + ) + _ = try await alternativePaymentsService.authenticate(request: authenticationRequest) + didOpenUrl = true + default: + throw POFailure(errorDescription: "Unknown redirect type.", code: .Mobile.internal) } - let task = Task { - do { - let response = try await serviceAdapter.continuePayment( - with: .init( - flow: configuration.flow, - redirect: currentState.redirect.confirmationRequired ? .init(success: didOpenUrl) : nil, - localeIdentifier: configuration.localization.localeOverride?.identifier - ) - ) - try await setState(with: response) - } catch { - setFailureState(error: error) - } + let response = try await serviceAdapter.continuePayment( + with: .init( + flow: configuration.flow, + redirect: redirect.confirmationRequired ? .init(success: didOpenUrl) : nil, + localeIdentifier: configuration.localization.localeOverride?.identifier + ) + ) + try await setState(with: response) + } + + private func openDeepLink(url: URL) async -> Bool { + let options: [UIApplication.OpenExternalURLOptionsKey: Any] + if url.scheme == "https" || url.scheme == "http" { // Determines whether link could be universal + options = [.universalLinksOnly: true] + } else { + options = [:] } - let newState = State.Redirecting(task: task, snapshot: currentState) - state = .redirecting(newState) + return await UIApplication.shared.open(url, options: options) } // MARK: - Completed State @@ -935,18 +910,6 @@ final class NativeAlternativePaymentDefaultInteractor: return nil } - // MARK: - Redirect Utils - - private func openDeepLink(url: URL) async -> Bool { - let options: [UIApplication.OpenExternalURLOptionsKey: Any] - if url.scheme == "https" || url.scheme == "http" { // Determines whether link could be universal - options = [.universalLinksOnly: true] - } else { - options = [:] - } - return await UIApplication.shared.open(url, options: options) - } - // MARK: - External Events private func observeEvents() { diff --git a/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/ViewModel/DefaultNativeAlternativePaymentViewModel.swift b/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/ViewModel/DefaultNativeAlternativePaymentViewModel.swift index 3d4b0a51b..991961b02 100644 --- a/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/ViewModel/DefaultNativeAlternativePaymentViewModel.swift +++ b/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/ViewModel/DefaultNativeAlternativePaymentViewModel.swift @@ -305,7 +305,7 @@ final class DefaultNativeAlternativePaymentViewModel: ViewModel { ) -> NativeAlternativePaymentViewModelControlGroup? { var buttons: [POButtonViewModel] = [ createRedirectButton(state: state, isRedirecting: isRedirecting) - ] + ].compactMap(\.self) let cancelButton = createCancelButton( configuration: interactor.configuration.cancelButton, isEnabled: state.isCancellable ) @@ -324,11 +324,14 @@ final class DefaultNativeAlternativePaymentViewModel: ViewModel { private func createRedirectButton( state: InteractorState.AwaitingRedirect, isRedirecting: Bool - ) -> POButtonViewModel { - // todo(andrii-vysotskyi): decide whether button should be customizable + ) -> POButtonViewModel? { + guard let buttonConfiguration = interactor.configuration.redirect.redirectButton else { + return nil + } let viewModel = POButtonViewModel( id: "redirect-button", - title: state.redirect.hint, + title: buttonConfiguration.title ?? state.redirect.hint, + icon: buttonConfiguration.icon, isEnabled: true, isLoading: isRedirecting, role: .primary,