Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-24 14:29:08 +03:00
parent e70f8be7c8
commit 494c9c0127
13 changed files with 508 additions and 94 deletions

View file

@ -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<ActionButtonConfig> {
fun getActionButtonsConfig(
isAddFundsEnabled: Boolean,
isWithdrawEnabled: Boolean,
): ImmutableList<TangemPayActionButtonUM> {
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,
),
),
)
}

View file

@ -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<ActionButtonConfig>
abstract val actionButtons: ImmutableList<TangemPayActionButtonUM>
abstract val cardsBlockState: CardsBlockState?
data class Loading(
override val actionButtons: ImmutableList<ActionButtonConfig>,
override val actionButtons: ImmutableList<TangemPayActionButtonUM>,
override val cardsBlockState: CardsBlockState?,
) : TangemPayDetailsBalanceBlockState()
data class Content(
override val actionButtons: ImmutableList<ActionButtonConfig>,
override val actionButtons: ImmutableList<TangemPayActionButtonUM>,
override val cardsBlockState: CardsBlockState?,
val fiatBalance: TextReference,
val isBalanceFlickering: Boolean,
) : TangemPayDetailsBalanceBlockState()
data class Error(
override val actionButtons: ImmutableList<ActionButtonConfig>,
override val actionButtons: ImmutableList<TangemPayActionButtonUM>,
override val cardsBlockState: CardsBlockState?,
) : TangemPayDetailsBalanceBlockState()

View file

@ -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<TangemPayDetailsUM>
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)
}

View file

@ -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<TangemPayActionButtonUM>,
) : Transformer<TangemPayDetailsUM> {
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))
}
}

View file

@ -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<TangemPayDetailsUM> {
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)
}
}

View file

@ -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<T
pullToRefreshConfig = PullToRefreshConfig(isRefreshing = false, onRefresh = {}),
balanceBlockState = TangemPayDetailsBalanceBlockState.Content(
actionButtons = persistentListOf(
ActionButtonConfig(
text = resourceReference(id = R.string.common_receive),
iconResId = R.drawable.ic_arrow_down_24,
onClick = {},
TangemPayActionButtonUM(
action = TangemPayAction.AddFunds,
config = ActionButtonConfig(
text = resourceReference(id = R.string.common_receive),
iconResId = R.drawable.ic_arrow_down_24,
onClick = {},
),
),
),
fiatBalance = combinedReference(

View file

@ -33,7 +33,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.ui.components.SpacerH12
import com.tangem.core.ui.components.SpacerH24
import com.tangem.core.ui.components.SpacerW
import com.tangem.core.ui.components.buttons.actions.ActionButtonConfig
import com.tangem.core.ui.components.containers.pullToRefresh.TangemPullToRefreshSlidingContainer
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.components.text.applyBladeBrush
@ -417,7 +416,7 @@ private fun CardsBlock(
@Composable
private fun LazyItemScope.ActionBlock(
actionButtons: ImmutableList<ActionButtonConfig>,
actionButtons: ImmutableList<TangemPayActionButtonUM>,
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,
)
}
}

View file

@ -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,

View file

@ -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<TangemPayActionButtonUM>.withdrawButton: ActionButtonConfig
get() = first { it.action == TangemPayAction.Withdraw }.config
internal val List<TangemPayActionButtonUM>.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

View file

@ -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,
)

View file

@ -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<TangemPayCard> = 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<TangemPayCard> = 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.

View file

@ -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<UserWalletId>()) } 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,
),
)
}
}

View file

@ -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,
),
)
}