From 494c9c0127ab227ec46ad1587509e5399e45e63a Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 24 Jun 2026 14:29:08 +0300 Subject: [PATCH] Updated on 2026-08-14 --- .../entity/TangemPayDetailsStateFactory.kt | 57 ++++-- .../tangempay/entity/TangemPayDetailsUM.kt | 16 +- .../tangempay/model/TangemPayDetailsModel.kt | 27 ++- .../TangemPayActionButtonsTransformer.kt | 18 ++ ...TangemPayFreezeUnfreezeStateTransformer.kt | 24 --- .../tangempay/ui/TangemPayDetailsScreen.kt | 14 +- .../tangempay/ui/TangemPayDetailsScreenV2.kt | 16 +- .../utils/PaymentAccountStatusExt.kt | 6 + .../tangempay/ActionButtonsTestExt.kt | 18 ++ .../tangempay/TangemPayTestFixtures.kt | 22 +++ .../TangemPayDetailsStateFactoryTest.kt | 134 +++++++++++--- .../model/TangemPayDetailsModelTest.kt | 171 ++++++++++++++++++ .../TangemPayActionButtonsTransformerTest.kt | 79 ++++++++ 13 files changed, 508 insertions(+), 94 deletions(-) create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayActionButtonsTransformer.kt delete mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayFreezeUnfreezeStateTransformer.kt create mode 100644 features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/ActionButtonsTestExt.kt create mode 100644 features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/TangemPayTestFixtures.kt create mode 100644 features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModelTest.kt create mode 100644 features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayActionButtonsTransformerTest.kt diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactory.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactory.kt index 735192a0ff..f2c556b185 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactory.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactory.kt @@ -10,7 +10,6 @@ import com.tangem.core.ui.extensions.themedColor import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.generated.icons.Icons import com.tangem.core.ui.res.generated.icons.ic_document_20 -import com.tangem.domain.models.StatusSource import com.tangem.domain.models.account.PaymentAccountStatusValue import com.tangem.domain.models.pay.TangemPayCard import com.tangem.domain.models.pay.TangemPayCardFrozenState @@ -18,6 +17,8 @@ import com.tangem.domain.models.pay.TangemPayCardState import com.tangem.domain.models.pay.isFrozen import com.tangem.features.tangempay.details.impl.R import com.tangem.features.tangempay.utils.TangemPayDetailIntents +import com.tangem.features.tangempay.utils.hasWithdrawableAmount +import com.tangem.features.tangempay.utils.isFresh import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -60,11 +61,12 @@ internal class TangemPayDetailsStateFactory( } fun getLoadedState(status: PaymentAccountStatusValue.Loaded): TangemPayDetailsUM { - val isFresh = status.source == StatusSource.ACTUAL && status.error == null + val isFresh = status.isFresh val hasUnfrozenCard = status.cards.any { it.frozenState == TangemPayCardFrozenState.Unfrozen } val hasIssuingCard = status.cards.any { it.state == TangemPayCardState.Issuing } val isAddCardEnabled = isFresh && !hasIssuingCard val areActionButtonsEnabled = isFresh && hasUnfrozenCard + val hasWithdrawableBalance = status.balance.hasWithdrawableAmount return TangemPayDetailsUM( topBarConfig = TangemPayDetailsTopBarConfig( onBackClick = onBack, @@ -77,7 +79,10 @@ internal class TangemPayDetailsStateFactory( onRefresh = intents::onRefreshSwipe, ), balanceBlockState = TangemPayDetailsBalanceBlockState.Loading( - actionButtons = getActionButtonsConfig(isEnabled = areActionButtonsEnabled), + actionButtons = getActionButtonsConfig( + isAddFundsEnabled = areActionButtonsEnabled, + isWithdrawEnabled = areActionButtonsEnabled && hasWithdrawableBalance, + ), cardsBlockState = TangemPayDetailsBalanceBlockState.CardsBlockState( cards = status.cards .let { if (isMultipleCardsEnabled) it else it.take(1) } @@ -113,7 +118,7 @@ internal class TangemPayDetailsStateFactory( else -> null } - fun getDeactivatedState(): TangemPayDetailsUM { + fun getDeactivatedState(hasWithdrawableBalance: Boolean): TangemPayDetailsUM { return TangemPayDetailsUM( topBarConfig = TangemPayDetailsTopBarConfig( onBackClick = onBack, @@ -126,7 +131,10 @@ internal class TangemPayDetailsStateFactory( onRefresh = intents::onRefreshSwipe, ), balanceBlockState = TangemPayDetailsBalanceBlockState.Loading( - actionButtons = getActionButtonsConfig(isEnabled = true), + actionButtons = getActionButtonsConfig( + isAddFundsEnabled = true, + isWithdrawEnabled = hasWithdrawableBalance, + ), cardsBlockState = null, ), isBalanceHidden = false, @@ -256,23 +264,32 @@ internal class TangemPayDetailsStateFactory( ) } - private fun getActionButtonsConfig(isEnabled: Boolean): ImmutableList { + fun getActionButtonsConfig( + isAddFundsEnabled: Boolean, + isWithdrawEnabled: Boolean, + ): ImmutableList { return persistentListOf( - ActionButtonConfig( - text = resourceReference(id = R.string.tangempay_card_details_add_funds), - iconResId = if (isRedesignEnabled) { - R.drawable.ic_arrow_down_24 - } else { - R.drawable.ic_plus_24 - }, - onClick = intents::onClickAddFunds, - isEnabled = isEnabled, + TangemPayActionButtonUM( + action = TangemPayAction.AddFunds, + config = ActionButtonConfig( + text = resourceReference(id = R.string.tangempay_card_details_add_funds), + iconResId = if (isRedesignEnabled) { + R.drawable.ic_arrow_down_24 + } else { + R.drawable.ic_plus_24 + }, + onClick = intents::onClickAddFunds, + isEnabled = isAddFundsEnabled, + ), ), - ActionButtonConfig( - text = resourceReference(id = R.string.tangempay_card_details_withdraw), - iconResId = R.drawable.ic_arrow_up_24, - onClick = intents::onClickWithdraw, - isEnabled = isEnabled, + TangemPayActionButtonUM( + action = TangemPayAction.Withdraw, + config = ActionButtonConfig( + text = resourceReference(id = R.string.tangempay_card_details_withdraw), + iconResId = R.drawable.ic_arrow_up_24, + onClick = intents::onClickWithdraw, + isEnabled = isWithdrawEnabled, + ), ), ) } diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt index 93713fe0a9..541ff3efb8 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt @@ -68,26 +68,34 @@ internal sealed interface DisplayNameState { } } +internal enum class TangemPayAction { AddFunds, Withdraw } + +@Immutable +internal data class TangemPayActionButtonUM( + val action: TangemPayAction, + val config: ActionButtonConfig, +) + @Immutable internal sealed class TangemPayDetailsBalanceBlockState { - abstract val actionButtons: ImmutableList + abstract val actionButtons: ImmutableList abstract val cardsBlockState: CardsBlockState? data class Loading( - override val actionButtons: ImmutableList, + override val actionButtons: ImmutableList, override val cardsBlockState: CardsBlockState?, ) : TangemPayDetailsBalanceBlockState() data class Content( - override val actionButtons: ImmutableList, + override val actionButtons: ImmutableList, override val cardsBlockState: CardsBlockState?, val fiatBalance: TextReference, val isBalanceFlickering: Boolean, ) : TangemPayDetailsBalanceBlockState() data class Error( - override val actionButtons: ImmutableList, + override val actionButtons: ImmutableList, override val cardsBlockState: CardsBlockState?, ) : TangemPayDetailsBalanceBlockState() diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt index 888788f83f..270a42f551 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt @@ -26,6 +26,7 @@ import com.tangem.domain.feedback.models.WalletMetaInfo import com.tangem.domain.models.TokenReceiveConfig import com.tangem.domain.models.account.PaymentAccountStatusValue import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.pay.TangemPayCardFrozenState import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier import com.tangem.domain.pay.model.TangemPayTopUpData @@ -113,7 +114,9 @@ internal class TangemPayDetailsModel @Inject constructor( val uiState: StateFlow field = MutableStateFlow( when { - params.initialStatus.isDeactivated -> stateFactory.getDeactivatedState() + params.initialStatus.isDeactivated -> stateFactory.getDeactivatedState( + hasWithdrawableBalance = params.initialStatus.balanceOrNull()?.hasWithdrawableAmount == true, + ) else -> stateFactory.getLoadingState() }, ) @@ -134,7 +137,11 @@ internal class TangemPayDetailsModel @Inject constructor( .onEach { state -> when (state) { is PaymentAccountStatusValue.Deactivated -> { - uiState.update { stateFactory.getDeactivatedState() } + uiState.update { + stateFactory.getDeactivatedState( + hasWithdrawableBalance = state.balance.hasWithdrawableAmount, + ) + } uiState.update(DetailsBalanceTransformer(state.balance.fiatBalance)) } is PaymentAccountStatusValue.Loaded -> { @@ -169,7 +176,21 @@ internal class TangemPayDetailsModel @Inject constructor( frozenStateJobHolder.cancel() cardDetailsRepository .cardFrozenState(cardId) - .onEach { uiState.update(TangemPayFreezeUnfreezeStateTransformer(cardFrozenState = it)) } + .onEach { frozenState -> + // Mirror getLoadedState gating so a live freeze update can't re-enable actions on stale data. + val isFresh = currentStatus.value.ifLoadedOrNull { it.isFresh } == true + val isUnfrozen = frozenState == TangemPayCardFrozenState.Unfrozen + val areActionButtonsEnabled = isFresh && isUnfrozen + val hasWithdrawableBalance = currentStatus.value.balanceOrNull()?.hasWithdrawableAmount == true + uiState.update( + TangemPayActionButtonsTransformer( + stateFactory.getActionButtonsConfig( + isAddFundsEnabled = areActionButtonsEnabled, + isWithdrawEnabled = areActionButtonsEnabled && hasWithdrawableBalance, + ), + ), + ) + } .launchIn(modelScope) .saveIn(frozenStateJobHolder) } diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayActionButtonsTransformer.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayActionButtonsTransformer.kt new file mode 100644 index 0000000000..bc325204a3 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayActionButtonsTransformer.kt @@ -0,0 +1,18 @@ +package com.tangem.features.tangempay.model.transformers + +import com.tangem.features.tangempay.entity.TangemPayActionButtonUM +import com.tangem.features.tangempay.entity.TangemPayDetailsBalanceBlockState +import com.tangem.features.tangempay.entity.TangemPayDetailsUM +import com.tangem.utils.transformer.Transformer +import kotlinx.collections.immutable.ImmutableList + +internal class TangemPayActionButtonsTransformer( + private val actionButtons: ImmutableList, +) : Transformer { + + override fun transform(prevState: TangemPayDetailsUM): TangemPayDetailsUM { + val balanceBlockState = prevState.balanceBlockState + if (balanceBlockState !is TangemPayDetailsBalanceBlockState.Content) return prevState + return prevState.copy(balanceBlockState = balanceBlockState.copy(actionButtons = actionButtons)) + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayFreezeUnfreezeStateTransformer.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayFreezeUnfreezeStateTransformer.kt deleted file mode 100644 index 0146ce7829..0000000000 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayFreezeUnfreezeStateTransformer.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.tangem.features.tangempay.model.transformers - -import com.tangem.domain.models.pay.TangemPayCardFrozenState -import com.tangem.features.tangempay.entity.TangemPayDetailsBalanceBlockState -import com.tangem.features.tangempay.entity.TangemPayDetailsUM -import com.tangem.utils.transformer.Transformer -import kotlinx.collections.immutable.toPersistentList - -internal class TangemPayFreezeUnfreezeStateTransformer( - private val cardFrozenState: TangemPayCardFrozenState, -) : Transformer { - - override fun transform(prevState: TangemPayDetailsUM): TangemPayDetailsUM { - val balanceBlockState = if (prevState.balanceBlockState is TangemPayDetailsBalanceBlockState.Content) { - val actionButtons = prevState.balanceBlockState.actionButtons.map { - it.copy(isEnabled = cardFrozenState == TangemPayCardFrozenState.Unfrozen) - } - prevState.balanceBlockState.copy(actionButtons = actionButtons.toPersistentList()) - } else { - prevState.balanceBlockState - } - return prevState.copy(balanceBlockState = balanceBlockState) - } -} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt index 0a0f2dc41d..ea3be706fd 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt @@ -61,6 +61,7 @@ import com.tangem.features.tangempay.entity.* import com.tangem.features.tokendetails.ExpressTransactionsComponent import com.tangem.utils.StringsSigns.DASH_SIGN import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList private const val DISABLED_ALPHA = 0.5f @@ -283,7 +284,7 @@ private fun TangemPayDetailsBalanceBlock( if (state.actionButtons.isNotEmpty()) { HorizontalActionChips( modifier = Modifier.padding(top = 12.dp), - buttons = state.actionButtons, + buttons = state.actionButtons.map { it.config }.toImmutableList(), containerColor = TangemTheme.colors.background.primary, contentPadding = PaddingValues(horizontal = TangemTheme.dimens.spacing12), ) @@ -468,10 +469,13 @@ internal class TangemPayDetailsUMProvider : CollectionPreviewParameterProvider, + actionButtons: ImmutableList, modifier: Modifier = Modifier, ) { Row( @@ -427,14 +426,15 @@ private fun LazyItemScope.ActionBlock( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, ) { - actionButtons.fastForEach { actionConfig -> + actionButtons.fastForEach { actionButton -> + val config = actionButton.config TangemPayActionButton( modifier = Modifier.testTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON), - iconRes = actionConfig.iconResId, - onClick = actionConfig.onClick, - isEnabled = actionConfig.isEnabled, - isLoading = actionConfig.isInProgress, - title = actionConfig.text, + iconRes = config.iconResId, + onClick = config.onClick, + isEnabled = config.isEnabled, + isLoading = config.isInProgress, + title = config.text, ) } } diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/PaymentAccountStatusExt.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/PaymentAccountStatusExt.kt index 56139acdaa..3ac13a5110 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/PaymentAccountStatusExt.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/PaymentAccountStatusExt.kt @@ -42,6 +42,12 @@ internal fun AccountStatus.Payment.balanceOrNull(): PaymentAccountStatusValue.Ba else -> null } +internal val PaymentAccountStatusValue.Balance.hasWithdrawableAmount: Boolean + get() = availableForWithdrawal.signum() > 0 + +internal val PaymentAccountStatusValue.Loaded.isFresh: Boolean + get() = source == StatusSource.ACTUAL && error == null + internal fun AccountStatus.Payment.findCard( initialCardId: String, initialStatus: AccountStatus.Payment, diff --git a/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/ActionButtonsTestExt.kt b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/ActionButtonsTestExt.kt new file mode 100644 index 0000000000..aa059ab55c --- /dev/null +++ b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/ActionButtonsTestExt.kt @@ -0,0 +1,18 @@ +package com.tangem.features.tangempay + +import com.tangem.core.ui.components.buttons.actions.ActionButtonConfig +import com.tangem.features.tangempay.entity.TangemPayAction +import com.tangem.features.tangempay.entity.TangemPayActionButtonUM +import com.tangem.features.tangempay.entity.TangemPayDetailsUM + +internal val List.withdrawButton: ActionButtonConfig + get() = first { it.action == TangemPayAction.Withdraw }.config + +internal val List.addFundsButton: ActionButtonConfig + get() = first { it.action == TangemPayAction.AddFunds }.config + +internal val TangemPayDetailsUM.withdrawButton: ActionButtonConfig + get() = balanceBlockState.actionButtons.withdrawButton + +internal val TangemPayDetailsUM.addFundsButton: ActionButtonConfig + get() = balanceBlockState.actionButtons.addFundsButton \ No newline at end of file diff --git a/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/TangemPayTestFixtures.kt b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/TangemPayTestFixtures.kt new file mode 100644 index 0000000000..2a67c729c8 --- /dev/null +++ b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/TangemPayTestFixtures.kt @@ -0,0 +1,22 @@ +package com.tangem.features.tangempay + +import com.tangem.domain.models.pay.TangemPayCard +import com.tangem.domain.models.pay.TangemPayCardFrozenState +import com.tangem.domain.models.pay.TangemPayCardState + +internal fun tangemPayCard( + id: String = "card_1", + lastDigits: String = "1234", + frozenState: TangemPayCardFrozenState = TangemPayCardFrozenState.Unfrozen, + state: TangemPayCardState = TangemPayCardState.Active, +): TangemPayCard = TangemPayCard( + id = id, + productInstanceId = "product_1", + cardStatus = TangemPayCard.Status.ACTIVE, + hasPinCode = true, + displayName = null, + limit = null, + frozenState = frozenState, + lastDigits = lastDigits, + state = state, +) \ No newline at end of file diff --git a/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactoryTest.kt b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactoryTest.kt index c13bd904e7..04609c579d 100644 --- a/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactoryTest.kt +++ b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactoryTest.kt @@ -6,7 +6,10 @@ import com.tangem.domain.models.account.PaymentAccountStatusValue import com.tangem.domain.models.pay.TangemPayCard import com.tangem.domain.models.pay.TangemPayCardFrozenState import com.tangem.domain.models.pay.TangemPayCardState +import com.tangem.features.tangempay.addFundsButton +import com.tangem.features.tangempay.tangemPayCard import com.tangem.features.tangempay.utils.TangemPayDetailIntents +import com.tangem.features.tangempay.withdrawButton import io.mockk.clearMocks import io.mockk.every import io.mockk.mockk @@ -14,29 +17,15 @@ import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.MethodSource +import java.math.BigDecimal internal class TangemPayDetailsStateFactoryTest { private val intents: TangemPayDetailIntents = mockk(relaxed = true) - @BeforeEach - fun resetMocks() { - clearMocks(intents) - } + private val activeUnfrozenCard = tangemPayCard() - private val activeUnfrozenCard = TangemPayCard( - id = "card_1", - productInstanceId = "pi_card_1", - cardStatus = TangemPayCard.Status.ACTIVE, - hasPinCode = false, - displayName = null, - frozenState = TangemPayCardFrozenState.Unfrozen, - lastDigits = "1234", - limit = null, - state = TangemPayCardState.Active, - ) - - private fun createFactory() = TangemPayDetailsStateFactory( + private val factory = TangemPayDetailsStateFactory( onBack = {}, onOpenMenu = {}, intents = intents, @@ -45,14 +34,9 @@ internal class TangemPayDetailsStateFactoryTest { isMultipleCardsEnabled = true, ) - private fun loadedStatus( - statusSource: StatusSource, - statusError: PaymentAccountStatusValue.Error?, - statusCards: List = listOf(activeUnfrozenCard), - ): PaymentAccountStatusValue.Loaded = mockk(relaxed = true) { - every { source } returns statusSource - every { error } returns statusError - every { cards } returns statusCards + @BeforeEach + fun resetMocks() { + clearMocks(intents) } @ParameterizedTest @@ -61,14 +45,13 @@ internal class TangemPayDetailsStateFactoryTest { case: ButtonStateCase, ) { // Arrange - val factory = createFactory() val status = loadedStatus(statusSource = case.source, statusError = case.error) // Act val state = factory.getLoadedState(status) // Assert - val actionButtonsEnabled = state.balanceBlockState.actionButtons.map { it.isEnabled } + val actionButtonsEnabled = state.balanceBlockState.actionButtons.map { it.config.isEnabled } assertThat(actionButtonsEnabled).containsExactly(case.expectedEnabled, case.expectedEnabled) assertThat(state.balanceBlockState.cardsBlockState?.isAddCardEnabled).isEqualTo(case.expectedEnabled) // The card tile is intentionally NOT source-gated: it stays clickable on stale data as long as @@ -80,7 +63,6 @@ internal class TangemPayDetailsStateFactoryTest { @Test fun `GIVEN actual status with only frozen card WHEN getLoadedState THEN action buttons disabled`() { // Arrange - val factory = createFactory() val frozenCard = activeUnfrozenCard.copy(frozenState = TangemPayCardFrozenState.Frozen) val status = loadedStatus( statusSource = StatusSource.ACTUAL, @@ -92,14 +74,13 @@ internal class TangemPayDetailsStateFactoryTest { val state = factory.getLoadedState(status) // Assert - assertThat(state.balanceBlockState.actionButtons.map { it.isEnabled }).containsExactly(false, false) + assertThat(state.balanceBlockState.actionButtons.map { it.config.isEnabled }).containsExactly(false, false) assertThat(state.balanceBlockState.cardsBlockState?.isAddCardEnabled).isTrue() } @Test fun `GIVEN actual status with issuing card WHEN getLoadedState THEN add card disabled`() { // Arrange - val factory = createFactory() val issuingCard = activeUnfrozenCard.copy(state = TangemPayCardState.Issuing) val status = loadedStatus( statusSource = StatusSource.ACTUAL, @@ -114,6 +95,87 @@ internal class TangemPayDetailsStateFactoryTest { assertThat(state.balanceBlockState.cardsBlockState?.isAddCardEnabled).isFalse() } + @ParameterizedTest + @MethodSource("provideBalanceCases") + fun `GIVEN fresh status WHEN getLoadedState THEN withdraw gated by balance but add funds enabled`( + case: BalanceCase, + ) { + // Arrange + val status = loadedStatus(availableForWithdrawal = case.availableForWithdrawal) + + // Act + val state = factory.getLoadedState(status) + + // Assert + assertThat(state.addFundsButton.isEnabled).isTrue() + assertThat(state.withdrawButton.isEnabled).isEqualTo(case.expectedWithdrawEnabled) + } + + @Test + fun `GIVEN deactivated with positive balance WHEN getDeactivatedState THEN withdraw enabled`() { + // Act + val state = factory.getDeactivatedState(hasWithdrawableBalance = true) + + // Assert + assertThat(state.addFundsButton.isEnabled).isTrue() + assertThat(state.withdrawButton.isEnabled).isTrue() + } + + @Test + fun `GIVEN deactivated with zero balance WHEN getDeactivatedState THEN withdraw disabled`() { + // Act + val state = factory.getDeactivatedState(hasWithdrawableBalance = false) + + // Assert + assertThat(state.addFundsButton.isEnabled).isTrue() + assertThat(state.withdrawButton.isEnabled).isFalse() + } + + @Test + fun `GIVEN withdraw disabled WHEN getActionButtonsConfig THEN withdraw disabled and add funds enabled`() { + // Act + val buttons = factory.getActionButtonsConfig(isAddFundsEnabled = true, isWithdrawEnabled = false) + + // Assert + assertThat(buttons.addFundsButton.isEnabled).isTrue() + assertThat(buttons.withdrawButton.isEnabled).isFalse() + } + + @Test + fun `GIVEN both enabled WHEN getActionButtonsConfig THEN both buttons enabled`() { + // Act + val buttons = factory.getActionButtonsConfig(isAddFundsEnabled = true, isWithdrawEnabled = true) + + // Assert + assertThat(buttons.addFundsButton.isEnabled).isTrue() + assertThat(buttons.withdrawButton.isEnabled).isTrue() + } + + private fun loadedStatus( + statusSource: StatusSource = StatusSource.ACTUAL, + statusError: PaymentAccountStatusValue.Error? = null, + statusCards: List = listOf(activeUnfrozenCard), + availableForWithdrawal: BigDecimal = BigDecimal.TEN, + ): PaymentAccountStatusValue.Loaded = mockk(relaxed = true) { + every { source } returns statusSource + every { error } returns statusError + every { cards } returns statusCards + every { balance } returns PaymentAccountStatusValue.Balance( + fiatBalance = PaymentAccountStatusValue.FiatBalance( + availableBalance = BigDecimal.ZERO, + currency = "USD", + ), + cryptoBalance = PaymentAccountStatusValue.CryptoBalance( + id = "id", + chainId = 1L, + depositAddress = "address", + tokenContractAddress = "contract", + balance = BigDecimal.ZERO, + ), + availableForWithdrawal = availableForWithdrawal, + ) + } + internal data class ButtonStateCase( val source: StatusSource, val error: PaymentAccountStatusValue.Error?, @@ -121,7 +183,19 @@ internal class TangemPayDetailsStateFactoryTest { val expectedCardEnabled: Boolean, ) + internal data class BalanceCase( + val availableForWithdrawal: BigDecimal, + val expectedWithdrawEnabled: Boolean, + ) + private companion object { + @JvmStatic + fun provideBalanceCases() = listOf( + BalanceCase(availableForWithdrawal = BigDecimal.ZERO, expectedWithdrawEnabled = false), + BalanceCase(availableForWithdrawal = BigDecimal.TEN, expectedWithdrawEnabled = true), + BalanceCase(availableForWithdrawal = BigDecimal("-1"), expectedWithdrawEnabled = false), + ) + @JvmStatic fun provideButtonStateCases() = listOf( // Fresh data from the network -> actions allowed, card tile clickable. diff --git a/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModelTest.kt b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModelTest.kt new file mode 100644 index 0000000000..c511c80187 --- /dev/null +++ b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModelTest.kt @@ -0,0 +1,171 @@ +package com.tangem.features.tangempay.model + +import arrow.core.right +import com.google.common.truth.Truth.assertThat +import com.tangem.core.decompose.model.MutableParamsContainer +import com.tangem.domain.models.StatusSource +import com.tangem.domain.models.account.AccountStatus +import com.tangem.domain.models.account.PaymentAccountStatusValue +import com.tangem.domain.models.pay.TangemPayCardFrozenState +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier +import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository +import com.tangem.features.tangempay.addFundsButton +import com.tangem.features.tangempay.components.TangemPayDetailsContainerComponent +import com.tangem.features.tangempay.tangemPayCard +import com.tangem.features.tangempay.withdrawButton +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource +import java.math.BigDecimal + +@OptIn(ExperimentalCoroutinesApi::class) +internal class TangemPayDetailsModelTest { + + private val userWalletId = UserWalletId("123") + + private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier = mockk() + private val cardDetailsRepository: TangemPayCardDetailsRepository = mockk(relaxed = true) + + @ParameterizedTest + @MethodSource("provideFreezeCases") + fun `GIVEN frozen state and balance WHEN status loaded THEN action buttons gated accordingly`( + case: FreezeCase, + ) = runTest { + // Arrange + Act + val model = createModel( + testScope = this, + statusSource = case.statusSource, + frozenState = case.frozenState, + availableForWithdrawal = case.availableForWithdrawal, + ) + advanceUntilIdle() + + // Assert + val state = model.uiState.value + assertThat(state.addFundsButton.isEnabled).isEqualTo(case.expectedAddFundsEnabled) + assertThat(state.withdrawButton.isEnabled).isEqualTo(case.expectedWithdrawEnabled) + model.onDestroy() + } + + private fun createModel( + testScope: TestScope, + statusSource: StatusSource, + frozenState: TangemPayCardFrozenState, + availableForWithdrawal: BigDecimal, + ): TangemPayDetailsModel { + val loaded: PaymentAccountStatusValue.Loaded = mockk(relaxed = true) { + every { source } returns statusSource + every { error } returns null + every { cards } returns listOf(tangemPayCard()) + every { balance } returns PaymentAccountStatusValue.Balance( + fiatBalance = PaymentAccountStatusValue.FiatBalance( + availableBalance = BigDecimal.ZERO, + currency = "USD", + ), + cryptoBalance = PaymentAccountStatusValue.CryptoBalance( + id = "id", + chainId = 1L, + depositAddress = "address", + tokenContractAddress = "contract", + balance = BigDecimal.ZERO, + ), + availableForWithdrawal = availableForWithdrawal, + ) + } + val paymentStatus: AccountStatus.Payment = mockk(relaxed = true) { + every { value } returns loaded + every { account } returns mockk(relaxed = true) { + every { userWalletId } returns this@TangemPayDetailsModelTest.userWalletId + } + } + val params = TangemPayDetailsContainerComponent.Params(initialStatus = paymentStatus) + + every { paymentAccountStatusSupplier.invoke(any()) } returns flowOf(paymentStatus) + every { cardDetailsRepository.cardFrozenState(any()) } returns flowOf(frozenState) + coEvery { cardDetailsRepository.isAddToWalletDone(any()) } returns false.right() + + return TangemPayDetailsModel( + paramsContainer = MutableParamsContainer(params), + paymentAccountStatusSupplier = paymentAccountStatusSupplier, + dispatchers = testScope.createTestingCoroutineDispatcherProvider(), + analytics = mockk(relaxed = true), + router = mockk(relaxed = true), + urlOpener = mockk(relaxed = true), + cardDetailsRepository = cardDetailsRepository, + getBalanceHidingSettingsUseCase = mockk(relaxed = true), + uiMessageSender = mockk(relaxed = true), + txHistoryUpdateListener = mockk(relaxed = true), + tangemPayWithdrawRepository = mockk(relaxed = true), + sendFeedbackEmailUseCase = mockk(relaxed = true), + expressTransactionsEventListener = mockk(relaxed = true), + tangemPayFeatureToggles = mockk(relaxed = true), + paymentAccountStatusFetcher = mockk(relaxed = true), + produceTangemPayInitialDataUseCase = mockk(relaxed = true), + onboardingRepository = mockk(relaxed = true), + getCustomerOffers = mockk(relaxed = true), + ) + } + + private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider { + val testDispatcher = StandardTestDispatcher(testScheduler) + return TestingCoroutineDispatcherProvider( + main = testDispatcher, + mainImmediate = testDispatcher, + io = testDispatcher, + default = testDispatcher, + single = testDispatcher, + ) + } + + internal data class FreezeCase( + val statusSource: StatusSource, + val frozenState: TangemPayCardFrozenState, + val availableForWithdrawal: BigDecimal, + val expectedAddFundsEnabled: Boolean, + val expectedWithdrawEnabled: Boolean, + ) + + private companion object { + @JvmStatic + fun provideFreezeCases() = listOf( + FreezeCase( + statusSource = StatusSource.ACTUAL, + frozenState = TangemPayCardFrozenState.Unfrozen, + availableForWithdrawal = BigDecimal.ZERO, + expectedAddFundsEnabled = true, + expectedWithdrawEnabled = false, + ), + FreezeCase( + statusSource = StatusSource.ACTUAL, + frozenState = TangemPayCardFrozenState.Unfrozen, + availableForWithdrawal = BigDecimal.TEN, + expectedAddFundsEnabled = true, + expectedWithdrawEnabled = true, + ), + FreezeCase( + statusSource = StatusSource.ACTUAL, + frozenState = TangemPayCardFrozenState.Frozen, + availableForWithdrawal = BigDecimal.TEN, + expectedAddFundsEnabled = false, + expectedWithdrawEnabled = false, + ), + FreezeCase( + statusSource = StatusSource.CACHE, + frozenState = TangemPayCardFrozenState.Unfrozen, + availableForWithdrawal = BigDecimal.TEN, + expectedAddFundsEnabled = false, + expectedWithdrawEnabled = false, + ), + ) + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayActionButtonsTransformerTest.kt b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayActionButtonsTransformerTest.kt new file mode 100644 index 0000000000..8e4494200a --- /dev/null +++ b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayActionButtonsTransformerTest.kt @@ -0,0 +1,79 @@ +package com.tangem.features.tangempay.model.transformers + +import com.google.common.truth.Truth.assertThat +import com.tangem.core.ui.components.buttons.actions.ActionButtonConfig +import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.features.tangempay.details.impl.R +import com.tangem.features.tangempay.entity.TangemPayAction +import com.tangem.features.tangempay.entity.TangemPayActionButtonUM +import com.tangem.features.tangempay.entity.TangemPayDetailsBalanceBlockState +import com.tangem.features.tangempay.entity.TangemPayDetailsTopBarConfig +import com.tangem.features.tangempay.entity.TangemPayDetailsUM +import kotlinx.collections.immutable.persistentListOf +import org.junit.jupiter.api.Test + +internal class TangemPayActionButtonsTransformerTest { + + @Test + fun `GIVEN content balance block WHEN transform THEN action buttons are replaced`() { + // Arrange + val newButtons = persistentListOf(actionButton(enabled = false)) + val transformer = TangemPayActionButtonsTransformer(newButtons) + + // Act + val result = transformer.transform(contentState()) + + // Assert + assertThat(result.balanceBlockState.actionButtons).isEqualTo(newButtons) + } + + @Test + fun `GIVEN non-content balance block WHEN transform THEN state is unchanged`() { + // Arrange + val transformer = TangemPayActionButtonsTransformer(persistentListOf(actionButton())) + val state = contentState().copy( + balanceBlockState = TangemPayDetailsBalanceBlockState.Loading( + actionButtons = persistentListOf(), + cardsBlockState = null, + ), + ) + + // Act + val result = transformer.transform(state) + + // Assert + assertThat(result).isEqualTo(state) + } + + private fun contentState(): TangemPayDetailsUM = TangemPayDetailsUM( + topBarConfig = TangemPayDetailsTopBarConfig( + onBackClick = {}, + onOpenMenu = {}, + items = persistentListOf(), + itemsV2 = persistentListOf(), + ), + pullToRefreshConfig = PullToRefreshConfig(isRefreshing = false, onRefresh = {}), + balanceBlockState = TangemPayDetailsBalanceBlockState.Content( + actionButtons = persistentListOf(actionButton()), + cardsBlockState = null, + fiatBalance = TextReference.EMPTY, + isBalanceFlickering = false, + ), + addToWalletBlockState = null, + isBalanceHidden = false, + errorNotificationConfig = null, + accountDeactivatedNotificationConfig = null, + ) + + private fun actionButton(enabled: Boolean = true): TangemPayActionButtonUM = TangemPayActionButtonUM( + action = TangemPayAction.Withdraw, + config = ActionButtonConfig( + text = resourceReference(R.string.tangempay_card_details_withdraw), + iconResId = R.drawable.ic_arrow_up_24, + onClick = {}, + isEnabled = enabled, + ), + ) +} \ No newline at end of file