Skip to content

Commit

Permalink
feat(POM-203): native APM cancellation when waiting customer action (#…
Browse files Browse the repository at this point in the history
…101)

* Support canceling APM flow while waiting capture confirmation
* Allow disabling cancel button for specific initial period
  • Loading branch information
andrii-vysotskyi-cko authored May 18, 2023
1 parent d1de0d0 commit ecf3532
Show file tree
Hide file tree
Showing 8 changed files with 108 additions and 43 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ final class AlternativePaymentMethodsRouter: RouterType {
switch route {
case let .nativeAlternativePayment(route):
let configuration = PONativeAlternativePaymentMethodConfiguration(
secondaryAction: .cancel()
secondaryAction: .cancel(),
paymentConfirmationSecondaryAction: .cancel(disabledFor: 10)
)
let viewController = PONativeAlternativePaymentMethodViewControllerBuilder
.with(invoiceId: route.invoiceId, gatewayConfigurationId: route.gatewayConfigurationId)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,12 +141,15 @@ final class DefaultNativeAlternativePaymentMethodInteractor:
}

func cancel() {
guard case .started = state else {
logger.debug("Will attempt to cancel payment \(configuration.invoiceId)")
switch state {
case .started:
setFailureStateUnchecked(failure: POFailure(code: .cancelled))
case .awaitingCapture:
captureCancellable?.cancel()
default:
logger.info("Ignored cancellation attempt from unsupported state: \(String(describing: state))")
return
}
logger.debug("Will cancel payment \(configuration.invoiceId)")
setFailureStateUnchecked(failure: POFailure(code: .cancelled))
}

// MARK: - Private Nested Types
Expand Down Expand Up @@ -218,13 +221,6 @@ final class DefaultNativeAlternativePaymentMethodInteractor:
return
}
send(event: .willWaitForCaptureConfirmation(additionalActionExpected: expectedActionMessage != nil))
let awaitingCaptureState = State.AwaitingCapture(
gatewayLogoImage: gatewayLogo,
expectedActionMessage: expectedActionMessage,
actionImage: actionImage
)
state = .awaitingCapture(awaitingCaptureState)
logger.info("Waiting for invoice \(configuration.invoiceId) capture confirmation")
let request = PONativeAlternativePaymentCaptureRequest(
invoiceId: configuration.invoiceId,
gatewayConfigurationId: configuration.gatewayConfigurationId,
Expand All @@ -239,6 +235,13 @@ final class DefaultNativeAlternativePaymentMethodInteractor:
self?.setFailureStateUnchecked(failure: failure)
}
}
let awaitingCaptureState = State.AwaitingCapture(
gatewayLogoImage: gatewayLogo,
expectedActionMessage: expectedActionMessage,
actionImage: actionImage
)
state = .awaitingCapture(awaitingCaptureState)
logger.info("Waiting for invoice \(configuration.invoiceId) capture confirmation")
}

private func setCapturedState() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,13 @@ public struct PONativeAlternativePaymentMethodConfiguration {

public enum SecondaryAction {

/// Cancel action. Use `nil` for default title.
case cancel(title: String? = nil)
/// Cancel action.
///
/// - Parameters:
/// - title: Action title. Pass `nil` title to use default value.
/// - disabledFor: By default user can interact with action immediately after it becomes visible, it is
/// possible to make it initialy disabled for given amount of time.
case cancel(title: String? = nil, disabledFor: TimeInterval = 0)
}

/// Custom title.
Expand All @@ -40,14 +45,19 @@ public struct PONativeAlternativePaymentMethodConfiguration {
/// Maximum value is 180 seconds.
public let paymentConfirmationTimeout: TimeInterval

/// Action that could be optionally presented to user during payment confirmation stage. To remove action
/// use `nil`, this is default behaviour.
public let paymentConfirmationSecondaryAction: SecondaryAction?

public init(
title: String? = nil,
successMessage: String? = nil,
primaryActionTitle: String? = nil,
secondaryAction: SecondaryAction? = nil,
skipSuccessScreen: Bool = false,
waitsPaymentConfirmation: Bool = true,
paymentConfirmationTimeout: TimeInterval = 180
paymentConfirmationTimeout: TimeInterval = 180,
paymentConfirmationSecondaryAction: SecondaryAction? = nil
) {
self.title = title
self.successMessage = successMessage
Expand All @@ -56,5 +66,6 @@ public struct PONativeAlternativePaymentMethodConfiguration {
self.skipSuccessScreen = skipSuccessScreen
self.waitsPaymentConfirmation = waitsPaymentConfirmation
self.paymentConfirmationTimeout = paymentConfirmationTimeout
self.paymentConfirmationSecondaryAction = paymentConfirmationSecondaryAction
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,13 @@ final class NativeAlternativePaymentMethodButtonsView: UIView {
}

func configure(actions: NativeAlternativePaymentMethodViewModelState.Actions, animated: Bool) {
configure(button: primaryButton, action: actions.primary, animated: animated)
if let action = actions.secondary {
configure(button: secondaryButton, action: action, animated: animated)
secondaryButton.setHidden(false)
if actions.primary != nil || actions.secondary != nil {
let animated = animated && alpha > 0
configure(button: primaryButton, withAction: actions.primary, animated: animated)
configure(button: secondaryButton, withAction: actions.secondary, animated: animated)
alpha = 1
} else {
secondaryButton.setHidden(true)
alpha = 0
}
}

Expand All @@ -52,6 +53,7 @@ final class NativeAlternativePaymentMethodButtonsView: UIView {
view.translatesAutoresizingMaskIntoConstraints = false
view.spacing = Constants.spacing
view.axis = .vertical
view.alignment = .fill
return view
}()

Expand Down Expand Up @@ -80,15 +82,25 @@ final class NativeAlternativePaymentMethodButtonsView: UIView {
contentView.leadingAnchor.constraint(equalTo: safeAreaLayoutGuide.leadingAnchor, constant: horizontalInset),
contentView.centerXAnchor.constraint(equalTo: safeAreaLayoutGuide.centerXAnchor),
contentView.topAnchor.constraint(equalTo: topAnchor, constant: Constants.verticalInset),
contentView.heightAnchor.constraint(equalToConstant: 0).with(priority: .defaultLow),
bottomConstraint
]
NSLayoutConstraint.activate(constraints)
}

private func configure(
button: Button, action: NativeAlternativePaymentMethodViewModelState.Action, animated: Bool
button: Button, withAction action: NativeAlternativePaymentMethodViewModelState.Action?, animated: Bool
) {
let viewModel = Button.ViewModel(title: action.title, isLoading: action.isExecuting, handler: action.handler)
guard let action else {
button.setHidden(true)
button.alpha = 0
return
}
let viewModel = Button.ViewModel(
title: action.title, isLoading: action.isExecuting, handler: action.handler
)
button.configure(viewModel: viewModel, isEnabled: action.isEnabled, animated: animated)
button.setHidden(false)
button.alpha = 1
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,7 @@ final class NativeAlternativePaymentMethodViewController<ViewModel: NativeAltern
// MARK: - State Management

private func configureWithIdleState() {
buttonsContainerView.alpha = 0
buttonsContainerView.configure(actions: .init(primary: nil, secondary: nil), animated: false)
let snapshot = DiffableDataSourceSnapshot<SectionIdentifier, ItemIdentifier>()
collectionViewDataSource.applySnapshotUsingReloadData(snapshot)
}
Expand All @@ -310,12 +310,8 @@ final class NativeAlternativePaymentMethodViewController<ViewModel: NativeAltern
self?.updateFirstResponder()
}
UIView.perform(withAnimation: animated, duration: Constants.animationDuration) { [self] in
if let actions = state.actions {
buttonsContainerView.configure(actions: actions, animated: buttonsContainerView.alpha > 0 && animated)
buttonsContainerView.alpha = 1
} else {
buttonsContainerView.alpha = 0
}
buttonsContainerView.configure(actions: state.actions, animated: animated)
collectionOverlayView.layoutIfNeeded()
}
}

Expand Down Expand Up @@ -501,12 +497,11 @@ final class NativeAlternativePaymentMethodViewController<ViewModel: NativeAltern
// todo(andrii-vysotskyi): consider observing overlay content height instead for better flexibility in future
var bottomInset = Constants.contentInset.bottom + keyboardHeight
if case .started(let startedState) = state {
if let actions = startedState.actions {
if actions.secondary != nil {
bottomInset += Constants.overlayLargeContentHeight
} else {
bottomInset += Constants.overlaySmallContentHeight
}
let actions = startedState.actions
if actions.primary != nil, actions.secondary != nil {
bottomInset += Constants.overlayLargeContentHeight
} else if actions.primary != nil || actions.secondary != nil {
bottomInset += Constants.overlaySmallContentHeight
}
}
if bottomInset != collectionView.contentInset.bottom {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ final class DefaultNativeAlternativePaymentMethodViewModel:
self.completion = completion
inputValuesObservations = []
inputValuesCache = [:]
isPaymentCancelDisabled = false
isCaptureCancelDisabled = false
timers = [:]
super.init(state: .idle)
observeInteractorStateChanges()
}
Expand Down Expand Up @@ -59,6 +62,9 @@ final class DefaultNativeAlternativePaymentMethodViewModel:

private var inputValuesCache: [String: State.InputValue]
private var inputValuesObservations: [AnyObject]
private var timers: [AnyHashable: Timer]
private var isPaymentCancelDisabled: Bool
private var isCaptureCancelDisabled: Bool

// MARK: - Private Methods

Expand All @@ -73,6 +79,9 @@ final class DefaultNativeAlternativePaymentMethodViewModel:
case .starting:
configureWithStartingState()
case .started(let startedState):
scheduleCancelActionEnabling(
configuration: configuration.secondaryAction, isDisabled: \.isPaymentCancelDisabled
)
state = convertToState(startedState: startedState, isSubmitting: false)
case .failure(let failure):
completion?(.failure(failure))
Expand All @@ -81,6 +90,9 @@ final class DefaultNativeAlternativePaymentMethodViewModel:
case .submitted:
completion?(.success(()))
case .awaitingCapture(let awaitingCaptureState):
scheduleCancelActionEnabling(
configuration: configuration.paymentConfirmationSecondaryAction, isDisabled: \.isCaptureCancelDisabled
)
state = convertToState(awaitingCaptureState: awaitingCaptureState)
case .captured(let capturedState):
configure(with: capturedState)
Expand All @@ -91,7 +103,9 @@ final class DefaultNativeAlternativePaymentMethodViewModel:
let sections = [
State.Section(id: .init(id: nil, title: nil, decoration: .normal), items: [.loader])
]
let startedState = State.Started(sections: sections, actions: nil, isEditingAllowed: false)
let startedState = State.Started(
sections: sections, actions: .init(primary: nil, secondary: nil), isEditingAllowed: false
)
state = .started(startedState)
}

Expand Down Expand Up @@ -124,7 +138,10 @@ final class DefaultNativeAlternativePaymentMethodViewModel:
sections: sections,
actions: .init(
primary: submitAction(startedState: startedState, isSubmitting: isSubmitting),
secondary: cancelAction(isEnabled: !isSubmitting)
secondary: cancelAction(
configuration: configuration.secondaryAction,
isEnabled: !isSubmitting && !isPaymentCancelDisabled
)
),
isEditingAllowed: !isSubmitting
)
Expand All @@ -144,11 +161,15 @@ final class DefaultNativeAlternativePaymentMethodViewModel:
} else {
item = .loader
}
let secondaryAction = cancelAction(
configuration: configuration.paymentConfirmationSecondaryAction,
isEnabled: !isCaptureCancelDisabled
)
let startedState = State.Started(
sections: [
.init(id: .init(id: nil, title: nil, decoration: .normal), items: [item])
],
actions: nil,
actions: .init(primary: nil, secondary: secondaryAction),
isEditingAllowed: false
)
return .started(startedState)
Expand All @@ -175,7 +196,7 @@ final class DefaultNativeAlternativePaymentMethodViewModel:
sections: [
.init(id: .init(id: nil, title: nil, decoration: .success), items: [.submitted(submittedItem)])
],
actions: nil,
actions: .init(primary: nil, secondary: nil),
isEditingAllowed: false
)
state = .started(startedState)
Expand Down Expand Up @@ -208,8 +229,10 @@ final class DefaultNativeAlternativePaymentMethodViewModel:
return action
}

private func cancelAction(isEnabled: Bool) -> State.Action? {
guard case let .cancel(title) = configuration.secondaryAction else {
private func cancelAction(
configuration: PONativeAlternativePaymentMethodConfiguration.SecondaryAction?, isEnabled: Bool
) -> State.Action? {
guard case let .cancel(title, _) = configuration else {
return nil
}
let action = State.Action(
Expand Down Expand Up @@ -296,4 +319,22 @@ final class DefaultNativeAlternativePaymentMethodViewModel:
return Text.Phone.placeholder
}
}

// MARK: - Cancel Actions Enabling

private func scheduleCancelActionEnabling(
configuration: PONativeAlternativePaymentMethodConfiguration.SecondaryAction?,
isDisabled: ReferenceWritableKeyPath<DefaultNativeAlternativePaymentMethodViewModel, Bool>
) {
let timerKey = AnyHashable(isDisabled)
guard !timers.keys.contains(timerKey), case .cancel(_, let interval) = configuration, interval > 0 else {
return
}
self[keyPath: isDisabled] = true
let timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { [weak self] _ in
self?[keyPath: isDisabled] = false
self?.configureWithInteractorState()
}
timers[timerKey] = timer
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ enum NativeAlternativePaymentMethodViewModelState {
struct Actions {

/// Primary action.
let primary: Action
let primary: Action?

/// Secondary action.
let secondary: Action?
Expand All @@ -170,7 +170,7 @@ enum NativeAlternativePaymentMethodViewModelState {
let sections: [Section]

/// Available actions.
let actions: Actions?
let actions: Actions

/// Boolean value indicating whether editing is allowed.
let isEditingAllowed: Bool
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,13 +121,15 @@ final class Button: UIControl {
label.adjustsFontForContentSizeCategory = false
label.setContentHuggingPriority(.required, for: .horizontal)
label.setContentCompressionResistancePriority(.required, for: .horizontal)
label.alpha = 0
return label
}()

private lazy var activityIndicatorView: POActivityIndicatorView = {
let view = ActivityIndicatorViewFactory().create(style: style.activityIndicator)
view.hidesWhenStopped = false
view.setAnimating(true)
view.alpha = 0
return view
}()

Expand Down

0 comments on commit ecf3532

Please sign in to comment.