diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/common/composeui/WooPosTheme.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/common/composeui/WooPosTheme.kt index f5c8e4718ee..a3e3b9243e6 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/common/composeui/WooPosTheme.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/common/composeui/WooPosTheme.kt @@ -21,6 +21,7 @@ data class CustomColors( val totalsBackground: Color, val totalsErrorBackground: Color, val paymentSuccessBackground: Color, + val paymentProcessingBackground: Color, val paymentSuccessText: Color, val paymentSuccessIcon: Color, val dialogSubtitleHighlightBackground: Color = Color(0x14747480), @@ -196,7 +197,8 @@ private val DarkCustomColors = CustomColors( paymentSuccessBackground = WooPosColors.darkCustomColorsHomeBackground, paymentSuccessText = WooPosColors.oldGrayLight, paymentSuccessIcon = WooPosColors.darkCustomColorsHomeBackground, - homeBackground = WooPosColors.darkCustomColorsHomeBackground + homeBackground = WooPosColors.darkCustomColorsHomeBackground, + paymentProcessingBackground = WooPosColors.WooPurple70, ) private val LightCustomColors = CustomColors( @@ -205,11 +207,12 @@ private val LightCustomColors = CustomColors( success = WooPosColors.greenNotFromPalette, error = WooPosColors.lightCustomColorsError, totalsErrorBackground = WooPosColors.lightQuaternaryBackground, - totalsBackground = Color(0xFFF6F7F7), + totalsBackground = WooPosColors.Gray0, paymentSuccessBackground = WooPosColors.White, paymentSuccessText = WooPosColors.Purple90, paymentSuccessIcon = Color.White, - homeBackground = WooPosColors.Gray0 + homeBackground = WooPosColors.Gray0, + paymentProcessingBackground = WooPosColors.WooPurple70, ) private val LocalCustomColors = staticCompositionLocalOf { diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/common/composeui/component/WooPosButtons.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/common/composeui/component/WooPosButtons.kt index 2544188965f..61db50bac5b 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/common/composeui/component/WooPosButtons.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/common/composeui/component/WooPosButtons.kt @@ -2,6 +2,7 @@ package com.woocommerce.android.ui.woopos.common.composeui.component import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -91,6 +92,26 @@ fun WooPosOutlinedButton( text: String, shape: RoundedCornerShape = RoundedCornerShape(4.dp), onClick: () -> Unit, +) = WooPosOutlinedButton( + modifier = modifier, + shape = shape, + content = { + Text( + text = text, + color = MaterialTheme.colors.primary, + style = MaterialTheme.typography.body2, + fontWeight = FontWeight.SemiBold, + ) + }, + onClick = onClick, +) + +@Composable +fun WooPosOutlinedButton( + modifier: Modifier = Modifier, + shape: RoundedCornerShape = RoundedCornerShape(4.dp), + content: @Composable RowScope.() -> Unit, + onClick: () -> Unit, ) { Button( modifier = modifier, @@ -107,15 +128,9 @@ fun WooPosOutlinedButton( disabledElevation = 0.dp, hoveredElevation = 0.dp, focusedElevation = 0.dp - ) - ) { - Text( - text = text, - color = MaterialTheme.colors.primary, - style = MaterialTheme.typography.body2, - fontWeight = FontWeight.SemiBold, - ) - } + ), + content = content + ) } @Composable diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeChildToParentCommunication.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeChildToParentCommunication.kt index c0b8ac6c640..b43c0f2f298 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeChildToParentCommunication.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeChildToParentCommunication.kt @@ -23,6 +23,11 @@ sealed class ChildToParentEvent { data object BackFromCheckoutToCartClicked : ChildToParentEvent() data class ItemClickedInProductSelector(val itemData: WooPosItemsViewModel.ItemClickedData) : ChildToParentEvent() data object NewTransactionClicked : ChildToParentEvent() + data object PaymentCollecting : ChildToParentEvent() + data object PaymentProcessing : ChildToParentEvent() + data object PaymentFailed : ChildToParentEvent() + data object RetryFailedPaymentClicked : ChildToParentEvent() + data object GoBackToCheckoutAfterFailedPayment : ChildToParentEvent() data object OrderSuccessfullyPaid : ChildToParentEvent() data object ExitPosClicked : ChildToParentEvent() data object ProductsDialogInfoIconClicked : ChildToParentEvent() diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeScreen.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeScreen.kt index 11972135e4c..c9a18901319 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeScreen.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeScreen.kt @@ -89,18 +89,18 @@ private fun WooPosHomeScreen( WooPosHomeState.ScreenPositionState.Cart.Hidden -> screenWidthDp is WooPosHomeState.ScreenPositionState.Cart.Visible, - WooPosHomeState.ScreenPositionState.Checkout.NotPaid -> productsWidthDp + WooPosHomeState.ScreenPositionState.Checkout.CartWithTotals -> productsWidthDp - WooPosHomeState.ScreenPositionState.Checkout.Paid -> productsWidthDp - cartWidthDp + WooPosHomeState.ScreenPositionState.Checkout.FullScreenTotals -> productsWidthDp - cartWidthDp }, label = "productsWidthAnimatedDp" ) val totalsWidthAnimatedDp by animateDpAsState( when (state.screenPositionState) { - is WooPosHomeState.ScreenPositionState.Checkout.Paid -> screenWidthDp + is WooPosHomeState.ScreenPositionState.Checkout.FullScreenTotals -> screenWidthDp is WooPosHomeState.ScreenPositionState.Cart, - WooPosHomeState.ScreenPositionState.Checkout.NotPaid -> totalsWidthDp + WooPosHomeState.ScreenPositionState.Checkout.CartWithTotals -> totalsWidthDp }, label = "totalsWidthAnimatedDp" ) @@ -261,7 +261,7 @@ fun WooPosHomeCheckoutScreenPreview() { WooPosTheme { WooPosHomeScreen( state = WooPosHomeState( - screenPositionState = WooPosHomeState.ScreenPositionState.Checkout.NotPaid, + screenPositionState = WooPosHomeState.ScreenPositionState.Checkout.CartWithTotals, productsInfoDialog = ProductsInfoDialog(isVisible = false), exitConfirmationDialog = WooPosHomeState.ExitConfirmationDialog(isVisible = false), ), @@ -277,7 +277,7 @@ fun WooPosHomeCheckoutPaidScreenPreview() { WooPosTheme { WooPosHomeScreen( state = WooPosHomeState( - screenPositionState = WooPosHomeState.ScreenPositionState.Checkout.Paid, + screenPositionState = WooPosHomeState.ScreenPositionState.Checkout.FullScreenTotals, productsInfoDialog = ProductsInfoDialog(isVisible = false), exitConfirmationDialog = WooPosHomeState.ExitConfirmationDialog(isVisible = false), ), diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeState.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeState.kt index 177e58b10e4..10f9f309c78 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeState.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeState.kt @@ -26,10 +26,10 @@ data class WooPosHomeState( @Parcelize sealed class Checkout : ScreenPositionState() { @Parcelize - data object NotPaid : Checkout() + data object CartWithTotals : Checkout() @Parcelize - data object Paid : Checkout() + data object FullScreenTotals : Checkout() } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeViewModel.kt index b7ebdffaac7..2b4df204a94 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeViewModel.kt @@ -45,14 +45,14 @@ class WooPosHomeViewModel @Inject constructor( return when (event) { WooPosHomeUIEvent.SystemBackClicked -> { when (_state.value.screenPositionState) { - WooPosHomeState.ScreenPositionState.Checkout.NotPaid -> { + WooPosHomeState.ScreenPositionState.Checkout.CartWithTotals -> { _state.value = _state.value.copy( screenPositionState = WooPosHomeState.ScreenPositionState.Cart.Visible ) sendEventToChildren(ParentToChildrenEvent.BackFromCheckoutToCartClicked) } - WooPosHomeState.ScreenPositionState.Checkout.Paid -> { + WooPosHomeState.ScreenPositionState.Checkout.FullScreenTotals -> { _state.value = _state.value.copy( screenPositionState = WooPosHomeState.ScreenPositionState.Cart.Visible ) @@ -81,13 +81,14 @@ class WooPosHomeViewModel @Inject constructor( } } + @Suppress("LongMethod") private fun listenBottomEvents() { viewModelScope.launch { childrenToParentEventReceiver.events.collect { event -> when (event) { is ChildToParentEvent.CheckoutClicked -> { _state.value = _state.value.copy( - screenPositionState = WooPosHomeState.ScreenPositionState.Checkout.NotPaid + screenPositionState = WooPosHomeState.ScreenPositionState.Checkout.CartWithTotals ) sendEventToChildren(ParentToChildrenEvent.CheckoutClicked(event.productIds)) } @@ -96,6 +97,7 @@ class WooPosHomeViewModel @Inject constructor( _state.value = _state.value.copy( screenPositionState = WooPosHomeState.ScreenPositionState.Cart.Visible ) + sendEventToChildren(ParentToChildrenEvent.BackFromCheckoutToCartClicked) } is ChildToParentEvent.ItemClickedInProductSelector -> { @@ -111,9 +113,24 @@ class WooPosHomeViewModel @Inject constructor( sendEventToChildren(ParentToChildrenEvent.OrderSuccessfullyPaid) } - is ChildToParentEvent.OrderSuccessfullyPaid -> { + is ChildToParentEvent.PaymentCollecting -> { + _state.value = _state.value.copy( + screenPositionState = WooPosHomeState.ScreenPositionState.Checkout.CartWithTotals + ) + } + + is ChildToParentEvent.PaymentProcessing, + is ChildToParentEvent.OrderSuccessfullyPaid, + is ChildToParentEvent.PaymentFailed -> { + _state.value = _state.value.copy( + screenPositionState = WooPosHomeState.ScreenPositionState.Checkout.FullScreenTotals + ) + } + + is ChildToParentEvent.GoBackToCheckoutAfterFailedPayment, + is ChildToParentEvent.RetryFailedPaymentClicked -> { _state.value = _state.value.copy( - screenPositionState = WooPosHomeState.ScreenPositionState.Checkout.Paid + screenPositionState = WooPosHomeState.ScreenPositionState.Checkout.CartWithTotals ) } @@ -156,8 +173,8 @@ class WooPosHomeViewModel @Inject constructor( WooPosHomeState.ScreenPositionState.Cart.Visible WooPosHomeState.ScreenPositionState.Cart.Visible, - WooPosHomeState.ScreenPositionState.Checkout.NotPaid, - WooPosHomeState.ScreenPositionState.Checkout.Paid -> screenPosition + WooPosHomeState.ScreenPositionState.Checkout.CartWithTotals, + WooPosHomeState.ScreenPositionState.Checkout.FullScreenTotals -> screenPosition } } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/cart/WooPosCartViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/cart/WooPosCartViewModel.kt index d876a51efa4..d62010e71af 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/cart/WooPosCartViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/cart/WooPosCartViewModel.kt @@ -117,8 +117,11 @@ class WooPosCartViewModel @Inject constructor( parentToChildrenEventReceiver.events.collect { event -> when (event) { is ParentToChildrenEvent.BackFromCheckoutToCartClicked -> handleBackFromCheckoutToCartClicked() + is ParentToChildrenEvent.ItemClickedInProductSelector -> handleItemClickedInProductSelector(event) - is ParentToChildrenEvent.OrderSuccessfullyPaid -> handleOrderSuccessfullyPaid() + + is ParentToChildrenEvent.OrderSuccessfullyPaid -> clearCart() + is ParentToChildrenEvent.CheckoutClicked -> Unit } } @@ -151,7 +154,7 @@ class WooPosCartViewModel @Inject constructor( } } - private fun handleOrderSuccessfullyPaid() { + private fun clearCart() { _state.value = WooPosCartState() } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/totals/WooPosTotalsRepository.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/totals/WooPosTotalsRepository.kt index a197ca2aece..d94b80978ab 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/totals/WooPosTotalsRepository.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/totals/WooPosTotalsRepository.kt @@ -1,6 +1,8 @@ package com.woocommerce.android.ui.woopos.home.totals import com.woocommerce.android.model.Order +import com.woocommerce.android.model.OrderMapper +import com.woocommerce.android.tools.SelectedSite import com.woocommerce.android.ui.orders.creation.OrderCreateEditRepository import com.woocommerce.android.ui.woopos.common.data.WooPosGetProductById import com.woocommerce.android.util.DateUtils @@ -8,13 +10,17 @@ import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.async import kotlinx.coroutines.withContext +import org.wordpress.android.fluxc.store.WCOrderStore import java.util.Date import javax.inject.Inject class WooPosTotalsRepository @Inject constructor( private val orderCreateEditRepository: OrderCreateEditRepository, private val dateUtils: DateUtils, - private val getProductById: WooPosGetProductById + private val getProductById: WooPosGetProductById, + private val orderStore: WCOrderStore, + private val selectedSite: SelectedSite, + private val orderMapper: OrderMapper, ) { private var orderCreationJob: Deferred>? = null @@ -59,6 +65,12 @@ class WooPosTotalsRepository @Inject constructor( } } + suspend fun getOrderById(orderId: Long) = withContext(IO) { + orderStore.getOrderByIdAndSite(orderId, selectedSite.get())?.let { + orderMapper.toAppModel(it) + } + } + private companion object { /** * This magic value used to indicate that we don't want to send subtotals and totals diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/totals/WooPosTotalsScreen.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/totals/WooPosTotalsScreen.kt index 965ebb6130a..f1687b010cc 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/totals/WooPosTotalsScreen.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/totals/WooPosTotalsScreen.kt @@ -45,6 +45,8 @@ import com.woocommerce.android.ui.woopos.common.composeui.component.WooPosButton import com.woocommerce.android.ui.woopos.common.composeui.component.WooPosErrorScreen import com.woocommerce.android.ui.woopos.common.composeui.component.WooPosShimmerBox import com.woocommerce.android.ui.woopos.common.composeui.toAdaptivePadding +import com.woocommerce.android.ui.woopos.home.totals.payment.failed.WooPosPaymentFailedScreen +import com.woocommerce.android.ui.woopos.home.totals.payment.processing.WooPosPaymentProcessingScreen import com.woocommerce.android.ui.woopos.home.totals.payment.success.WooPosPaymentSuccessScreen @Composable @@ -87,6 +89,21 @@ private fun WooPosTotalsScreen( ) } } + + StateChangeAnimated(visible = state is WooPosTotalsViewState.PaymentProcessing) { + if (state is WooPosTotalsViewState.PaymentProcessing) { + WooPosPaymentProcessingScreen(state) + } + } + + StateChangeAnimated(visible = state is WooPosTotalsViewState.PaymentFailed) { + if (state is WooPosTotalsViewState.PaymentFailed) { + WooPosPaymentFailedScreen( + state = state, + onUIEvent = onUIEvent, + ) + } + } } } @@ -116,18 +133,25 @@ private fun TotalsLoaded( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { - val error = state.error - if (error != null) { - Box( - modifier = Modifier - .fillMaxWidth() - .weight(1.1f) - .background(WooPosTheme.colors.totalsErrorBackground) - ) { - TotalsError(modifier = Modifier, error = error) + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1.1f) + .background(WooPosTheme.colors.totalsErrorBackground) + ) { + val error = state.error + when { + error != null -> TotalsError(modifier = Modifier, error = error) + else -> { + Text( + modifier = Modifier.align(Alignment.Center), + text = state.paymentStateText, + style = MaterialTheme.typography.body1, + ) + } } } - TotalsGrid(modifier = Modifier.weight(1f), state = state) + TotalsGrid(modifier = Modifier.weight(.9f), state = state) } } @@ -213,10 +237,6 @@ private fun TotalsGrid( fontWeightOne = FontWeight.Medium, fontWeightTwo = FontWeight.Bold, ) - Text( - text = state.paymentStateText, - style = MaterialTheme.typography.body1, - ) } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/totals/WooPosTotalsUIEvent.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/totals/WooPosTotalsUIEvent.kt index e0841c61cc9..7d4d7df6ad1 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/totals/WooPosTotalsUIEvent.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/totals/WooPosTotalsUIEvent.kt @@ -2,5 +2,7 @@ package com.woocommerce.android.ui.woopos.home.totals sealed class WooPosTotalsUIEvent { data object OnNewTransactionClicked : WooPosTotalsUIEvent() + data object RetryFailedTransactionClicked : WooPosTotalsUIEvent() + data object GoBackToCheckoutAfterFailedPayment : WooPosTotalsUIEvent() data object RetryOrderCreationClicked : WooPosTotalsUIEvent() } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/totals/WooPosTotalsViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/totals/WooPosTotalsViewModel.kt index 832945c4d3e..ba351005d69 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/totals/WooPosTotalsViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/totals/WooPosTotalsViewModel.kt @@ -13,15 +13,20 @@ import com.woocommerce.android.ui.payments.cardreader.onboarding.CardReaderFlowP import com.woocommerce.android.ui.payments.cardreader.payment.controller.CardReaderPaymentController import com.woocommerce.android.ui.payments.cardreader.payment.controller.CardReaderPaymentControllerFactory import com.woocommerce.android.ui.payments.cardreader.payment.controller.CardReaderPaymentOrRefundState +import com.woocommerce.android.ui.payments.cardreader.payment.controller.CardReaderPaymentOrRefundState.CardReaderPaymentState import com.woocommerce.android.ui.woopos.cardreader.WooPosCardReaderFacade import com.woocommerce.android.ui.woopos.home.ChildToParentEvent import com.woocommerce.android.ui.woopos.home.ParentToChildrenEvent import com.woocommerce.android.ui.woopos.home.WooPosChildrenToParentEventSender import com.woocommerce.android.ui.woopos.home.WooPosParentToChildrenEventReceiver +import com.woocommerce.android.ui.woopos.home.totals.WooPosTotalsViewState.PaymentFailed +import com.woocommerce.android.ui.woopos.home.totals.WooPosTotalsViewState.PaymentProcessing +import com.woocommerce.android.ui.woopos.home.totals.WooPosTotalsViewState.PaymentSuccess import com.woocommerce.android.ui.woopos.util.WooPosNetworkStatus import com.woocommerce.android.ui.woopos.util.analytics.WooPosAnalyticsEvent import com.woocommerce.android.ui.woopos.util.analytics.WooPosAnalyticsTracker import com.woocommerce.android.ui.woopos.util.format.WooPosFormatPrice +import com.woocommerce.android.util.UiStringParser import com.woocommerce.android.util.WooLog import com.woocommerce.android.util.WooLog.T import com.woocommerce.android.viewmodel.ResourceProvider @@ -47,6 +52,7 @@ class WooPosTotalsViewModel @Inject constructor( private val analyticsTracker: WooPosAnalyticsTracker, private val networkStatus: WooPosNetworkStatus, private val cardReaderPaymentControllerFactory: CardReaderPaymentControllerFactory, + private val uiStringParser: UiStringParser, private val savedState: SavedStateHandle, ) : ViewModel() { @@ -65,7 +71,7 @@ class WooPosTotalsViewModel @Inject constructor( val state: StateFlow = uiState - private var dataState: MutableStateFlow = savedState.getStateFlow( + private val dataState: MutableStateFlow = savedState.getStateFlow( scope = viewModelScope, initialValue = TotalsDataState(), key = KEY_STATE, @@ -134,9 +140,39 @@ class WooPosTotalsViewModel @Inject constructor( is WooPosTotalsUIEvent.RetryOrderCreationClicked -> { createOrderDraft(dataState.value.productIds) } + WooPosTotalsUIEvent.GoBackToCheckoutAfterFailedPayment -> viewModelScope.launch { + childrenToParentEventSender.sendToParent(ChildToParentEvent.GoBackToCheckoutAfterFailedPayment) + retryPaymentCollectionFromScratch() + } + WooPosTotalsUIEvent.RetryFailedTransactionClicked -> viewModelScope.launch { + val paymentState = cardReaderPaymentController?.paymentState?.value + check(paymentState != null) { + "Retry failed transaction clicked but payment controller is null" + } + check(paymentState is CardReaderPaymentState.PaymentFailed.ExternalReaderFailedPayment) { + "Retry failed transaction clicked but payment state is not PaymentFailed" + } + when { + paymentState.onRetry != null -> { + paymentState.onRetry!!() + } + else -> { + childrenToParentEventSender.sendToParent(ChildToParentEvent.RetryFailedPaymentClicked) + retryPaymentCollectionFromScratch() + } + } + } } } + private suspend fun retryPaymentCollectionFromScratch() { + cancelPaymentAction() + val order = totalsRepository.getOrderById(dataState.value.orderId) + checkNotNull(order) + uiState.value = buildWooPosTotalsViewState(order) + collectPayment() + } + private fun collectPayment() { if (!networkStatus.isConnected()) { viewModelScope.launch { @@ -148,8 +184,6 @@ class WooPosTotalsViewModel @Inject constructor( if (cardReaderFacade.readerStatus.value is Connected) { val state = uiState.value check(state is WooPosTotalsViewState.Totals) - val orderId = dataState.value.orderId - check(orderId != EMPTY_ORDER_ID) check(uiState.value is WooPosTotalsViewState.Totals) createCardReaderPaymentController(dataState.value.orderId) cardReaderPaymentController?.start() @@ -182,23 +216,86 @@ class WooPosTotalsViewModel @Inject constructor( private fun listenToPaymentState() { viewModelScope.launch { cardReaderPaymentController?.paymentState?.collect { paymentState -> - val totalsState = uiState.value - if (totalsState is WooPosTotalsViewState.Totals) { - uiState.value = totalsState.copy( - paymentStateText = paymentState.javaClass.simpleName - ) - } - if (paymentState is CardReaderPaymentOrRefundState.CardReaderPaymentState.PaymentSuccessful) { - uiState.value = - WooPosTotalsViewState.PaymentSuccess( - orderTotalText = paymentState.amountWithCurrencyLabel - ) - childrenToParentEventSender.sendToParent(ChildToParentEvent.OrderSuccessfullyPaid) + when (paymentState) { + is CardReaderPaymentState.CollectingPayment, + is CardReaderPaymentState.LoadingData -> + handlePaymentState(paymentState) + + is CardReaderPaymentState.ProcessingPayment, + is CardReaderPaymentState.PaymentCapturing, + CardReaderPaymentState.ReFetchingOrder -> { + uiState.value = buildPaymentProcessingState() + childrenToParentEventSender.sendToParent(ChildToParentEvent.PaymentProcessing) + } + + is CardReaderPaymentState.PaymentSuccessful -> { + uiState.value = + PaymentSuccess(orderTotalText = paymentState.amountWithCurrencyLabel) + childrenToParentEventSender.sendToParent(ChildToParentEvent.OrderSuccessfullyPaid) + } + + is CardReaderPaymentState.PaymentFailed.ExternalReaderFailedPayment -> { + uiState.value = buildPaymentFailedState(paymentState) + childrenToParentEventSender.sendToParent(ChildToParentEvent.PaymentFailed) + } + + is CardReaderPaymentOrRefundState.CardReaderInteracRefundState, + is CardReaderPaymentState.PaymentFailed.BuiltInReaderFailedPayment, + is CardReaderPaymentState.PrintingReceipt, + CardReaderPaymentState.SharingReceipt -> { + throw IllegalArgumentException("Payment state: $paymentState not compatible with POS") + } } } } } + private suspend fun handlePaymentState(paymentState: CardReaderPaymentState) { + val totalsState = uiState.value + if (totalsState is WooPosTotalsViewState.Totals) { + uiState.value = totalsState.copy( + paymentStateText = paymentState.javaClass.simpleName + ) + } else { + val order = totalsRepository.getOrderById(dataState.value.orderId) + checkNotNull(order) + uiState.value = buildWooPosTotalsViewState(order, paymentState) + childrenToParentEventSender.sendToParent(ChildToParentEvent.PaymentCollecting) + } + } + + private suspend fun returnToCart() { + childrenToParentEventSender.sendToParent(ChildToParentEvent.BackFromCheckoutToCartClicked) + } + + private fun buildPaymentFailedState( + state: CardReaderPaymentState.PaymentFailed.ExternalReaderFailedPayment + ): PaymentFailed { + val isRetryAvailable = state.onRetry != null + val retryButtonLabel = if (isRetryAvailable) { + resourceProvider.getString(R.string.woo_pos_payment_failed_try_again) + } else { + resourceProvider.getString(R.string.woo_pos_payment_failed_try_another_payment_method) + } + return PaymentFailed( + title = resourceProvider.getString( + R.string.woopos_success_totals_payment_failed_title + ), + subtitle = uiStringParser.asString(state.errorType.message), + retryPaymentButtonLabel = retryButtonLabel, + isReturnToCheckoutButtonVisible = isRetryAvailable + ) + } + + private fun buildPaymentProcessingState(): PaymentProcessing = PaymentProcessing( + title = resourceProvider.getString( + R.string.woopos_success_totals_payment_processing_title + ), + subtitle = resourceProvider.getString( + R.string.woopos_success_totals_payment_processing_subtitle + ) + ) + override fun onCleared() { cardReaderPaymentController?.stop() } @@ -232,7 +329,10 @@ class WooPosTotalsViewModel @Inject constructor( } } - private suspend fun buildWooPosTotalsViewState(order: Order): WooPosTotalsViewState.Totals { + private suspend fun buildWooPosTotalsViewState( + order: Order, + paymentState: CardReaderPaymentState? = null + ): WooPosTotalsViewState.Totals { val subtotalAmount = order.productsTotal val taxAmount = order.totalTax val totalAmount = order.total @@ -245,7 +345,7 @@ class WooPosTotalsViewModel @Inject constructor( orderSubtotalText = priceFormat(subtotalAmount), orderTaxText = priceFormat(taxAmount), orderTotalText = priceFormat(totalAmount), - paymentStateText = "", + paymentStateText = paymentState?.javaClass?.simpleName ?: "", error = error ) } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/totals/WooPosTotalsViewState.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/totals/WooPosTotalsViewState.kt index ca5cb98029b..17feee449ed 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/totals/WooPosTotalsViewState.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/totals/WooPosTotalsViewState.kt @@ -23,6 +23,18 @@ sealed class WooPosTotalsViewState : Parcelable { ) : Parcelable } + data class PaymentProcessing( + val title: String, + val subtitle: String, + ) : WooPosTotalsViewState() + + data class PaymentFailed( + val title: String, + val subtitle: String, + val retryPaymentButtonLabel: String, + val isReturnToCheckoutButtonVisible: Boolean = false, + ) : WooPosTotalsViewState() + data class PaymentSuccess(var orderTotalText: String) : WooPosTotalsViewState() data class Error(val message: String) : WooPosTotalsViewState() diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/totals/payment/failed/WooPosTotalsPaymentFailedScreen.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/totals/payment/failed/WooPosTotalsPaymentFailedScreen.kt new file mode 100644 index 00000000000..49cc6dfea5d --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/totals/payment/failed/WooPosTotalsPaymentFailedScreen.kt @@ -0,0 +1,102 @@ +package com.woocommerce.android.ui.woopos.home.totals.payment.failed + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.woocommerce.android.R +import com.woocommerce.android.ui.woopos.common.composeui.WooPosPreview +import com.woocommerce.android.ui.woopos.common.composeui.WooPosTheme +import com.woocommerce.android.ui.woopos.common.composeui.component.WooPosButton +import com.woocommerce.android.ui.woopos.common.composeui.component.WooPosOutlinedButton +import com.woocommerce.android.ui.woopos.common.composeui.toAdaptivePadding +import com.woocommerce.android.ui.woopos.home.totals.WooPosTotalsUIEvent +import com.woocommerce.android.ui.woopos.home.totals.WooPosTotalsViewState + +@Composable +fun WooPosPaymentFailedScreen( + state: WooPosTotalsViewState.PaymentFailed, + onUIEvent: (WooPosTotalsUIEvent) -> Unit +) { + Column( + modifier = Modifier + .background(color = WooPosTheme.colors.homeBackground) + .fillMaxSize() + .padding(vertical = 96.dp.toAdaptivePadding()), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.height(96.dp.toAdaptivePadding())) + Icon( + modifier = Modifier.size(84.dp), + painter = painterResource(id = R.drawable.woo_pos_ic_error_x), + contentDescription = stringResource(id = R.string.woopos_error_icon_content_description), + tint = Color.Unspecified, + ) + Spacer(modifier = Modifier.height(40.dp.toAdaptivePadding())) + Text( + text = state.title, + style = MaterialTheme.typography.h4, + fontWeight = FontWeight.SemiBold + ) + Spacer(modifier = Modifier.height(16.dp.toAdaptivePadding())) + Text( + text = state.subtitle, + style = MaterialTheme.typography.h6 + ) + Spacer(modifier = Modifier.height(40.dp.toAdaptivePadding())) + WooPosButton( + text = state.retryPaymentButtonLabel, + modifier = Modifier + .height(80.dp) + .width(604.dp) + ) { onUIEvent(WooPosTotalsUIEvent.RetryFailedTransactionClicked) } + if (state.isReturnToCheckoutButtonVisible) { + Spacer(modifier = Modifier.height(24.dp.toAdaptivePadding())) + WooPosOutlinedButton( + modifier = Modifier + .height(80.dp) + .width(604.dp), + content = { + Text( + color = MaterialTheme.colors.primary, + style = MaterialTheme.typography.h5, + fontWeight = FontWeight.Bold, + text = stringResource(R.string.woo_pos_payment_failed_go_back_to_checkout) + ) + } + ) { onUIEvent(WooPosTotalsUIEvent.GoBackToCheckoutAfterFailedPayment) } + } + Spacer(modifier = Modifier.height(80.dp.toAdaptivePadding())) + } +} + +@WooPosPreview +@Composable +fun WooPosPaymentFailedScreenPreview() { + WooPosTheme { + WooPosPaymentFailedScreen( + state = WooPosTotalsViewState.PaymentFailed( + title = "Payment failed", + subtitle = "Unfortunately, this payment has been declined.", + retryPaymentButtonLabel = "Try again", + isReturnToCheckoutButtonVisible = true, + ), + onUIEvent = {} + ) + } +} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/totals/payment/processing/WooPosTotalsPaymentProcessingScreen.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/totals/payment/processing/WooPosTotalsPaymentProcessingScreen.kt new file mode 100644 index 00000000000..72d7af39682 --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/totals/payment/processing/WooPosTotalsPaymentProcessingScreen.kt @@ -0,0 +1,48 @@ +package com.woocommerce.android.ui.woopos.home.totals.payment.processing + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import com.woocommerce.android.ui.woopos.common.composeui.WooPosPreview +import com.woocommerce.android.ui.woopos.common.composeui.WooPosTheme +import com.woocommerce.android.ui.woopos.home.totals.WooPosTotalsViewState + +@Composable +fun WooPosPaymentProcessingScreen( + state: WooPosTotalsViewState.PaymentProcessing, +) { + Box( + modifier = Modifier + .background(color = WooPosTheme.colors.paymentProcessingBackground) + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text(text = state.title) + Text(text = state.subtitle) + } + } +} + +@WooPosPreview +@Composable +fun WooPosPaymentProcessingScreenPreview() { + WooPosTheme { + WooPosPaymentProcessingScreen( + state = WooPosTotalsViewState.PaymentProcessing( + title = "Processing payment", + subtitle = "Please wait...", + ) + ) + } +} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/util/UiStringParser.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/util/UiStringParser.kt new file mode 100644 index 00000000000..df79f5e7146 --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/util/UiStringParser.kt @@ -0,0 +1,12 @@ +package com.woocommerce.android.util + +import android.content.Context +import com.woocommerce.android.model.UiString +import com.woocommerce.android.util.UiHelpers.getTextOfUiString +import javax.inject.Inject + +class UiStringParser @Inject constructor( + private val context: Context +) { + fun asString(uiString: UiString): String = getTextOfUiString(context, uiString) +} diff --git a/WooCommerce/src/main/res/drawable/woo_pos_ic_error_x.xml b/WooCommerce/src/main/res/drawable/woo_pos_ic_error_x.xml new file mode 100644 index 00000000000..a0a0665e11e --- /dev/null +++ b/WooCommerce/src/main/res/drawable/woo_pos_ic_error_x.xml @@ -0,0 +1,12 @@ + + + + diff --git a/WooCommerce/src/main/res/values/strings.xml b/WooCommerce/src/main/res/values/strings.xml index 3214c9088b5..47dbd4d9931 100644 --- a/WooCommerce/src/main/res/values/strings.xml +++ b/WooCommerce/src/main/res/values/strings.xml @@ -4301,6 +4301,13 @@ To process this payment, please connect your reader. Connect to reader + Processing payment + Please wait… + Payment failed + Try another payment method + Try payment again + Go back to checkout + Dimmed background. Tap to close the menu. Card reader connected Card reader not connected. Double tap to connect @@ -4317,7 +4324,6 @@ "Failed to load more items. Please try again." Error loading variations - Customer Orders Registration diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeViewModelTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeViewModelTest.kt index 002e569a42c..e19c12dee6b 100644 --- a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeViewModelTest.kt +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeViewModelTest.kt @@ -273,6 +273,102 @@ class WooPosHomeViewModelTest { assertTrue(viewModel.state.value.screenPositionState is WooPosHomeState.ScreenPositionState.Checkout) } + @Test + fun `given home screen is at checkout, when go back to checkout clicked after failed payment, then should show cart with totals`() = runTest { + // GIVEN + val events = MutableSharedFlow() + whenever(childrenToParentEventReceiver.events).thenReturn(events) + + val viewModel: WooPosHomeViewModel = createViewModel() + events.emit(ChildToParentEvent.CheckoutClicked(listOf(1))) + assertThat( + viewModel.state.value.screenPositionState + ).isEqualTo(WooPosHomeState.ScreenPositionState.Checkout.CartWithTotals) + + // WHEN + events.emit(ChildToParentEvent.GoBackToCheckoutAfterFailedPayment) + + // THEN + assertThat( + viewModel.state.value.screenPositionState + ).isEqualTo(WooPosHomeState.ScreenPositionState.Checkout.CartWithTotals) + } + + @Test + fun `given home screen is at checkout, when payment processing started, then should show full screen totals state`() = runTest { + // GIVEN + val events = MutableSharedFlow() + whenever(childrenToParentEventReceiver.events).thenReturn(events) + + val viewModel: WooPosHomeViewModel = createViewModel() + events.emit(ChildToParentEvent.CheckoutClicked(listOf(1))) + assertThat( + viewModel.state.value.screenPositionState + ).isEqualTo(WooPosHomeState.ScreenPositionState.Checkout.CartWithTotals) + + // WHEN + events.emit(ChildToParentEvent.PaymentProcessing) + + // THEN + assertThat( + viewModel.state.value.screenPositionState + ).isEqualTo(WooPosHomeState.ScreenPositionState.Checkout.FullScreenTotals) + } + + @Test + fun `given home screen is at checkout, processing payment, when payment fails, then should show full screen totals state`() = runTest { + // GIVEN + val events = MutableSharedFlow() + whenever(childrenToParentEventReceiver.events).thenReturn(events) + + val viewModel: WooPosHomeViewModel = createViewModel() + events.emit(ChildToParentEvent.CheckoutClicked(listOf(1))) + assertThat( + viewModel.state.value.screenPositionState + ).isEqualTo(WooPosHomeState.ScreenPositionState.Checkout.CartWithTotals) + events.emit(ChildToParentEvent.PaymentProcessing) + assertThat( + viewModel.state.value.screenPositionState + ).isEqualTo(WooPosHomeState.ScreenPositionState.Checkout.FullScreenTotals) + + // WHEN + events.emit(ChildToParentEvent.PaymentFailed) + + // THEN + assertThat( + viewModel.state.value.screenPositionState + ).isEqualTo(WooPosHomeState.ScreenPositionState.Checkout.FullScreenTotals) + } + + @Test + fun `given home screen is at checkout, failed payment, when retry payment clicked, then should show cart with totals`() = runTest { + // GIVEN + val events = MutableSharedFlow() + whenever(childrenToParentEventReceiver.events).thenReturn(events) + + val viewModel: WooPosHomeViewModel = createViewModel() + events.emit(ChildToParentEvent.CheckoutClicked(listOf(1))) + assertThat( + viewModel.state.value.screenPositionState + ).isEqualTo(WooPosHomeState.ScreenPositionState.Checkout.CartWithTotals) + events.emit(ChildToParentEvent.PaymentProcessing) + assertThat( + viewModel.state.value.screenPositionState + ).isEqualTo(WooPosHomeState.ScreenPositionState.Checkout.FullScreenTotals) + events.emit(ChildToParentEvent.PaymentFailed) + assertThat( + viewModel.state.value.screenPositionState + ).isEqualTo(WooPosHomeState.ScreenPositionState.Checkout.FullScreenTotals) + + // WHEN + events.emit(ChildToParentEvent.RetryFailedPaymentClicked) + + // THEN + assertThat( + viewModel.state.value.screenPositionState + ).isEqualTo(WooPosHomeState.ScreenPositionState.Checkout.CartWithTotals) + } + private fun createViewModel() = WooPosHomeViewModel( childrenToParentEventReceiver, parentToChildrenEventSender, diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/home/totals/WooPosTotalsRepositoryTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/home/totals/WooPosTotalsRepositoryTest.kt index 2d5c07548ea..f65e5c1ed69 100644 --- a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/home/totals/WooPosTotalsRepositoryTest.kt +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/home/totals/WooPosTotalsRepositoryTest.kt @@ -1,6 +1,8 @@ package com.woocommerce.android.ui.woopos.home.totals import com.woocommerce.android.model.Order +import com.woocommerce.android.model.OrderMapper +import com.woocommerce.android.tools.SelectedSite import com.woocommerce.android.ui.orders.creation.OrderCreateEditRepository import com.woocommerce.android.ui.products.ProductHelper import com.woocommerce.android.ui.products.ProductType @@ -16,12 +18,16 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.store.WCOrderStore class WooPosTotalsRepositoryTest { private val orderCreateEditRepository: OrderCreateEditRepository = mock() private val getProductById: WooPosGetProductById = mock() private val dateUtils: DateUtils = mock() + private val orderStore: WCOrderStore = mock() + private val selectedSite: SelectedSite = mock() + private val orderMapper: OrderMapper = mock() private lateinit var repository: WooPosTotalsRepository @@ -33,11 +39,7 @@ class WooPosTotalsRepositoryTest { @Test fun `given empty product list, when createOrderWithProducts called, then return error`() = runTest { // GIVEN - repository = WooPosTotalsRepository( - orderCreateEditRepository, - dateUtils, - getProductById - ) + repository = createRepository() val productIds = emptyList() // WHEN @@ -50,11 +52,7 @@ class WooPosTotalsRepositoryTest { @Test fun `given product ids without duplicates, when createOrderWithProducts, then items all quantity one`() = runTest { // GIVEN - repository = WooPosTotalsRepository( - orderCreateEditRepository, - dateUtils, - getProductById - ) + repository = createRepository() val productIds = listOf(1L, 2L, 3L) whenever(getProductById(1L)).thenReturn(product1) @@ -79,11 +77,7 @@ class WooPosTotalsRepositoryTest { @Test fun `given product id, when createOrderWithProducts, then item name matches original product`() = runTest { // GIVEN - repository = WooPosTotalsRepository( - orderCreateEditRepository, - dateUtils, - getProductById - ) + repository = createRepository() val productIds = listOf(1L) whenever(getProductById(1L)).thenReturn(product1) @@ -105,11 +99,7 @@ class WooPosTotalsRepositoryTest { @Test fun `given product ids with duplicates, when createOrderWithProducts, then items quantity is correct`() = runTest { // GIVEN - repository = WooPosTotalsRepository( - orderCreateEditRepository, - dateUtils, - getProductById - ) + repository = createRepository() val productIds = listOf(1L, 1L, 2L, 3L, 3L, 3L) whenever(getProductById(1L)).thenReturn(product1) @@ -133,11 +123,7 @@ class WooPosTotalsRepositoryTest { @Test fun `given product ids, when createOrder with some invalid ids, then return failure`() = runTest { // GIVEN - repository = WooPosTotalsRepository( - orderCreateEditRepository, - dateUtils, - getProductById - ) + repository = createRepository() val productIds = listOf(1L, -1L, 3L) val mockOrder: Order = mock() whenever(orderCreateEditRepository.createOrUpdateOrder(any(), eq(""))).thenReturn(Result.success(mockOrder)) @@ -151,4 +137,13 @@ class WooPosTotalsRepositoryTest { assertThat(result.exceptionOrNull()?.message).isEqualTo("Invalid product ID: -1") verify(orderCreateEditRepository, never()).createOrUpdateOrder(any(), eq("")) } + + private fun createRepository() = WooPosTotalsRepository( + orderCreateEditRepository, + dateUtils, + getProductById, + orderStore, + selectedSite, + orderMapper, + ) } diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/home/totals/WooPosTotalsViewModelTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/home/totals/WooPosTotalsViewModelTest.kt index 89f2e96a15c..01980e4a979 100644 --- a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/home/totals/WooPosTotalsViewModelTest.kt +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/home/totals/WooPosTotalsViewModelTest.kt @@ -19,8 +19,11 @@ import com.woocommerce.android.ui.payments.cardreader.payment.CardReaderInteracR import com.woocommerce.android.ui.payments.cardreader.payment.CardReaderPaymentCollectibilityChecker import com.woocommerce.android.ui.payments.cardreader.payment.CardReaderPaymentErrorMapper import com.woocommerce.android.ui.payments.cardreader.payment.CardReaderPaymentOrderHelper +import com.woocommerce.android.ui.payments.cardreader.payment.PaymentFlowError import com.woocommerce.android.ui.payments.cardreader.payment.controller.CardReaderPaymentController import com.woocommerce.android.ui.payments.cardreader.payment.controller.CardReaderPaymentControllerFactory +import com.woocommerce.android.ui.payments.cardreader.payment.controller.CardReaderPaymentOrRefundState +import com.woocommerce.android.ui.payments.cardreader.payment.controller.CardReaderPaymentOrRefundState.CardReaderPaymentState import com.woocommerce.android.ui.payments.cardreader.payment.controller.CardReaderPaymentStateProvider import com.woocommerce.android.ui.payments.cardreader.payment.controller.CardReaderTrackCanceledFlowAction import com.woocommerce.android.ui.payments.receipt.PaymentReceiptHelper @@ -38,7 +41,9 @@ import com.woocommerce.android.ui.woopos.util.analytics.WooPosAnalyticsEvent import com.woocommerce.android.ui.woopos.util.analytics.WooPosAnalyticsTracker import com.woocommerce.android.ui.woopos.util.format.WooPosFormatPrice import com.woocommerce.android.util.CurrencyFormatter +import com.woocommerce.android.util.UiStringParser import com.woocommerce.android.viewmodel.ResourceProvider +import junit.framework.TestCase.assertTrue import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -51,6 +56,7 @@ import org.junit.Before import org.junit.Rule import org.mockito.kotlin.any import org.mockito.kotlin.atLeastOnce +import org.mockito.kotlin.clearInvocations import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify @@ -94,6 +100,7 @@ class WooPosTotalsViewModelTest { private val cardReaderOnboardingChecker: CardReaderOnboardingChecker = mock() private val cardReaderConfigProvider: CardReaderCountryConfigProvider = mock() private val paymentReceiptShare: PaymentReceiptShare = mock() + private val uiStringParser: UiStringParser = mock() private val paymentControllerFactory = CardReaderPaymentControllerFactory( cardReaderManager = cardReaderManager, orderRepository = orderRepository, @@ -283,7 +290,9 @@ class WooPosTotalsViewModelTest { val parentToChildrenEventReceiver: WooPosParentToChildrenEventReceiver = mock { on { events }.thenReturn(mock()) } + val savedState = createMockSavedStateHandle() + val viewModel = createViewModel( savedState = savedState, parentToChildrenEventReceiver = parentToChildrenEventReceiver, @@ -760,7 +769,7 @@ class WooPosTotalsViewModelTest { val mockCardReaderPaymentController: CardReaderPaymentController = mock() val factory: CardReaderPaymentControllerFactory = mock() - whenever(factory.create(any(), any(), any(), any())).thenReturn(mockCardReaderPaymentController) + whenever(factory.create(any(), any(), any())).thenReturn(mockCardReaderPaymentController) createViewModelAndSetupForSuccessfulOrderCreation(controllerFactory = factory) readerStatus.value = CardReaderStatus.Connected(mock()) @@ -778,19 +787,215 @@ class WooPosTotalsViewModelTest { val mockCardReaderPaymentController: CardReaderPaymentController = mock() val factory: CardReaderPaymentControllerFactory = mock() - whenever(factory.create(any(), any(), any(), any())).thenReturn(mockCardReaderPaymentController) + whenever(factory.create(any(), any(), any())).thenReturn(mockCardReaderPaymentController) val vm = createViewModelAndSetupForSuccessfulOrderCreation(controllerFactory = factory) // WHEN readerStatus.value = CardReaderStatus.NotConnected() // THEN - verify(mockCardReaderPaymentController).onCleared() + verify(mockCardReaderPaymentController).stop() verify(mockCardReaderPaymentController).onBackPressed() - assertThat(vm.paymentScope!!.isActive).isFalse } - private fun createViewModelAndSetupForSuccessfulOrderCreation( + @Test + fun `given order draft created and reader connected, when card tapped, should show payment processing screen`() = runTest { + // GIVEN + whenever( + resourceProvider.getString( + R.string.woopos_success_totals_payment_processing_title + ) + ).thenReturn("Processing payment") + whenever( + resourceProvider.getString( + R.string.woopos_success_totals_payment_processing_subtitle + ) + ).thenReturn("Please wait…") + whenever(networkStatus.isConnected()).thenReturn(true) + val readerStatus = MutableStateFlow(CardReaderStatus.Connected(mock())) + whenever(cardReaderFacade.readerStatus).thenReturn(readerStatus) + val mockCardReaderPaymentController: CardReaderPaymentController = mock() + val factory: CardReaderPaymentControllerFactory = mock() + whenever(factory.create(any(), any(), any())).thenReturn(mockCardReaderPaymentController) + val paymentState = + MutableStateFlow( + CardReaderPaymentState.CollectingPayment.ExternalReaderCollectPaymentState("") {} + ) + whenever(mockCardReaderPaymentController.paymentState).thenReturn(paymentState) + val vm = createViewModelAndSetupForSuccessfulOrderCreation(controllerFactory = factory) + + // WHEN + paymentState.value = CardReaderPaymentState.ProcessingPayment.ExternalReaderProcessingPayment("") {} + + // THEN + assertThat(vm.state.value).isInstanceOf(WooPosTotalsViewState.PaymentProcessing::class.java) + } + + @Test + fun `given payment failed with retry action, when retry clicked, then should retry previous payment action`() = runTest { + // GIVEN + whenever(resourceProvider.getString(R.string.woopos_success_totals_payment_processing_title)) + .thenReturn("Processing payment") + whenever(resourceProvider.getString(R.string.woopos_success_totals_payment_processing_subtitle)) + .thenReturn("Please wait…") + whenever(resourceProvider.getString(R.string.woopos_success_totals_payment_failed_title)) + .thenReturn("Payment failed") + whenever(uiStringParser.asString(any())).thenReturn("Unfortunately, this payment has been declined.") + whenever(resourceProvider.getString(R.string.woo_pos_payment_failed_try_again)) + .thenReturn("Try payment again") + + whenever(networkStatus.isConnected()).thenReturn(true) + val readerStatus = MutableStateFlow(CardReaderStatus.Connected(mock())) + whenever(cardReaderFacade.readerStatus).thenReturn(readerStatus) + val mockCardReaderPaymentController: CardReaderPaymentController = mock() + val factory: CardReaderPaymentControllerFactory = mock() + whenever(factory.create(any(), any(), any())).thenReturn(mockCardReaderPaymentController) + val paymentState = + MutableStateFlow( + CardReaderPaymentState.CollectingPayment.ExternalReaderCollectPaymentState("") {} + ) + whenever(mockCardReaderPaymentController.paymentState).thenReturn(paymentState) + val vm = createViewModelAndSetupForSuccessfulOrderCreation(controllerFactory = factory) + paymentState.value = CardReaderPaymentState.ProcessingPayment.ExternalReaderProcessingPayment("") {} + val failedPaymentRetryAction: () -> Unit = mock() + paymentState.value = CardReaderPaymentState.PaymentFailed.ExternalReaderFailedPayment.NonCancelable( + errorType = PaymentFlowError.NoNetwork, failedPaymentRetryAction + ) + assertThat(vm.state.value).isInstanceOf(WooPosTotalsViewState.PaymentFailed::class.java) + assertTrue( + (paymentState.value as CardReaderPaymentState.PaymentFailed.ExternalReaderFailedPayment).onRetry != null + ) + + // WHEN + vm.onUIEvent(WooPosTotalsUIEvent.RetryFailedTransactionClicked) + + // THEN + verify(failedPaymentRetryAction).invoke() + } + + @Test + fun `given payment failed without retry action, when retry clicked, then should cancel previous payment action and start again`() = + runTest { + // GIVEN + whenever(resourceProvider.getString(R.string.woopos_success_totals_payment_processing_title)) + .thenReturn("Processing payment") + whenever(resourceProvider.getString(R.string.woopos_success_totals_payment_processing_subtitle)) + .thenReturn("Please wait…") + whenever(resourceProvider.getString(R.string.woopos_success_totals_payment_failed_title)) + .thenReturn("Payment failed") + whenever(uiStringParser.asString(any())).thenReturn("Unfortunately, this payment has been declined.") + whenever(resourceProvider.getString(R.string.woo_pos_payment_failed_try_another_payment_method)) + .thenReturn("Try another payment method") + whenever(networkStatus.isConnected()).thenReturn(true) + val readerStatus = MutableStateFlow(CardReaderStatus.Connected(mock())) + whenever(cardReaderFacade.readerStatus).thenReturn(readerStatus) + val mockCardReaderPaymentController: CardReaderPaymentController = mock() + val factory: CardReaderPaymentControllerFactory = mock() + whenever(factory.create(any(), any(), any())).thenReturn(mockCardReaderPaymentController) + val paymentState = + MutableStateFlow( + CardReaderPaymentState.CollectingPayment.ExternalReaderCollectPaymentState("") {} + ) + whenever(mockCardReaderPaymentController.paymentState).thenReturn(paymentState) + val vm = createViewModelAndSetupForSuccessfulOrderCreation(controllerFactory = factory) + paymentState.value = CardReaderPaymentState.ProcessingPayment.ExternalReaderProcessingPayment("") {} + paymentState.value = CardReaderPaymentState.PaymentFailed.ExternalReaderFailedPayment.Cancelable( + errorType = PaymentFlowError.NoNetwork, onRetry = null, onCancel = {}, amountWithCurrencyLabel = "" + ) + assertThat(vm.state.value).isInstanceOf(WooPosTotalsViewState.PaymentFailed::class.java) + assertTrue( + (paymentState.value as CardReaderPaymentState.PaymentFailed.ExternalReaderFailedPayment).onRetry == null + ) + + // WHEN + clearInvocations(mockCardReaderPaymentController) + vm.onUIEvent(WooPosTotalsUIEvent.RetryFailedTransactionClicked) + + // THEN + verify(mockCardReaderPaymentController).stop() + verify(mockCardReaderPaymentController).start() + } + + @Test + fun `given payment failed without retry action, when retry clicked, then should go back to checkout`() = runTest { + // GIVEN + whenever(resourceProvider.getString(R.string.woopos_success_totals_payment_processing_title)) + .thenReturn("Processing payment") + whenever(resourceProvider.getString(R.string.woopos_success_totals_payment_processing_subtitle)) + .thenReturn("Please wait…") + whenever(resourceProvider.getString(R.string.woopos_success_totals_payment_failed_title)) + .thenReturn("Payment failed") + whenever(resourceProvider.getString(R.string.woo_pos_payment_failed_try_another_payment_method)) + .thenReturn("Try another payment method") + whenever(uiStringParser.asString(any())).thenReturn("Unfortunately, this payment has been declined.") + + whenever(networkStatus.isConnected()).thenReturn(true) + val readerStatus = MutableStateFlow(CardReaderStatus.Connected(mock())) + whenever(cardReaderFacade.readerStatus).thenReturn(readerStatus) + val mockCardReaderPaymentController: CardReaderPaymentController = mock() + val factory: CardReaderPaymentControllerFactory = mock() + whenever(factory.create(any(), any(), any())).thenReturn(mockCardReaderPaymentController) + val paymentState = + MutableStateFlow( + CardReaderPaymentState.CollectingPayment.ExternalReaderCollectPaymentState("") {} + ) + whenever(mockCardReaderPaymentController.paymentState).thenReturn(paymentState) + val vm = createViewModelAndSetupForSuccessfulOrderCreation(controllerFactory = factory) + paymentState.value = CardReaderPaymentState.ProcessingPayment.ExternalReaderProcessingPayment("") {} + paymentState.value = CardReaderPaymentState.PaymentFailed.ExternalReaderFailedPayment.Cancelable( + errorType = PaymentFlowError.NoNetwork, onRetry = null, onCancel = {}, amountWithCurrencyLabel = "" + ) + assertThat(vm.state.value).isInstanceOf(WooPosTotalsViewState.PaymentFailed::class.java) + assertTrue( + (paymentState.value as CardReaderPaymentState.PaymentFailed.ExternalReaderFailedPayment).onRetry == null + ) + + // WHEN + vm.onUIEvent(WooPosTotalsUIEvent.RetryFailedTransactionClicked) + + // THEN + verify(childrenToParentEventSender).sendToParent(ChildToParentEvent.RetryFailedPaymentClicked) + } + + @Test + fun `given payment failed, when go back to checkout clicked, then should inform home about the situation`() = runTest { + // GIVEN + whenever(resourceProvider.getString(R.string.woopos_success_totals_payment_processing_title)) + .thenReturn("Processing payment") + whenever(resourceProvider.getString(R.string.woopos_success_totals_payment_processing_subtitle)) + .thenReturn("Please wait…") + whenever(resourceProvider.getString(R.string.woopos_success_totals_payment_failed_title)) + .thenReturn("Payment failed") + whenever(resourceProvider.getString(R.string.woo_pos_payment_failed_try_again)) + .thenReturn("Try payment again") + whenever(uiStringParser.asString(any())).thenReturn("Unfortunately, this payment has been declined.") + + whenever(networkStatus.isConnected()).thenReturn(true) + val readerStatus = MutableStateFlow(CardReaderStatus.Connected(mock())) + whenever(cardReaderFacade.readerStatus).thenReturn(readerStatus) + val mockCardReaderPaymentController: CardReaderPaymentController = mock() + val factory: CardReaderPaymentControllerFactory = mock() + whenever(factory.create(any(), any(), any())).thenReturn(mockCardReaderPaymentController) + val paymentState = + MutableStateFlow( + CardReaderPaymentState.CollectingPayment.ExternalReaderCollectPaymentState("") {} + ) + whenever(mockCardReaderPaymentController.paymentState).thenReturn(paymentState) + val vm = createViewModelAndSetupForSuccessfulOrderCreation(controllerFactory = factory) + paymentState.value = CardReaderPaymentState.ProcessingPayment.ExternalReaderProcessingPayment("") {} + paymentState.value = CardReaderPaymentState.PaymentFailed.ExternalReaderFailedPayment.NonCancelable( + errorType = PaymentFlowError.NoNetwork, {} + ) + assertThat(vm.state.value).isInstanceOf(WooPosTotalsViewState.PaymentFailed::class.java) + + // WHEN + vm.onUIEvent(WooPosTotalsUIEvent.GoBackToCheckoutAfterFailedPayment) + + // THEN + verify(childrenToParentEventSender).sendToParent(ChildToParentEvent.GoBackToCheckoutAfterFailedPayment) + } + + private suspend fun createViewModelAndSetupForSuccessfulOrderCreation( controllerFactory: CardReaderPaymentControllerFactory = paymentControllerFactory ): WooPosTotalsViewModel { whenever(resourceProvider.getString(R.string.woopos_success_totals_error_reader_not_connected_title)) @@ -836,6 +1041,7 @@ class WooPosTotalsViewModelTest { val parentToChildrenEventReceiver: WooPosParentToChildrenEventReceiver = mock { on { events }.thenReturn(parentToChildrenEventFlow) } + whenever(totalsRepository.getOrderById(orderId)).thenReturn(order) return createViewModel( totalsRepository = totalsRepository, priceFormat = priceFormat, @@ -861,6 +1067,7 @@ class WooPosTotalsViewModelTest { analyticsTracker = analyticsTracker, networkStatus = networkStatus, cardReaderPaymentControllerFactory = cardReaderPaymentControllerFactory, + uiStringParser = uiStringParser, savedState = savedState, ) }