Updated on 2026-08-14

This commit is contained in:
Tangem 2026-05-14 13:12:57 +03:00
parent ad9def0d21
commit a9b5490cc4
46 changed files with 2679 additions and 163 deletions

View file

@ -21,9 +21,11 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDeta
import com.tangem.feature.tokendetails.presentation.tokendetails.route.TokenDetailsBottomSheetConfig
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.TokenDetailsScreen
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.TokenDetailsScreenLegacy
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet.AddFundsBottomSheetComponent
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet.ChooseAddressBottomSheetComponent
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet.CloreMigrationBottomSheetComponent
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet.DynamicAddressesBottomSheetComponent
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet.TransferBottomSheetComponent
import com.tangem.features.markets.token.block.TokenMarketBlockComponent
import com.tangem.features.tokendetails.ExpressTransactionsComponent
import com.tangem.features.tokendetails.TokenDetailsComponent
@ -159,6 +161,14 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor(
dynamicAddressesDelegate = model.dynamicAddressesDelegate,
onDismiss = model.bottomSheetNavigation::dismiss,
)
is TokenDetailsBottomSheetConfig.AddFunds -> AddFundsBottomSheetComponent(
stateFlow = model.addFundsUiState,
onDismiss = model.bottomSheetNavigation::dismiss,
)
is TokenDetailsBottomSheetConfig.Transfer -> TransferBottomSheetComponent(
stateFlow = model.transferUiState,
onDismiss = model.bottomSheetNavigation::dismiss,
)
}
@AssistedFactory

View file

@ -19,8 +19,16 @@ interface TokenDetailsClickIntents {
fun onSwapClick(unavailabilityReason: ScenarioUnavailabilityReason)
fun onSwapFromClick(unavailabilityReason: ScenarioUnavailabilityReason)
fun onSwapToClick(unavailabilityReason: ScenarioUnavailabilityReason)
fun onBuyClick(unavailabilityReason: ScenarioUnavailabilityReason)
fun onAddFundsClick()
fun onTransferClick()
fun onSellClick(unavailabilityReason: ScenarioUnavailabilityReason)
fun onHideClick()
@ -104,6 +112,10 @@ internal class EmptyTokenDetailsClickIntents : TokenDetailsClickIntents {
override fun onBuyClick(unavailabilityReason: ScenarioUnavailabilityReason) { /* no op */ }
override fun onAddFundsClick() { /* no op */ }
override fun onTransferClick() { /* no op */ }
override fun onBuyCoinClick(cryptoCurrency: CryptoCurrency) { /* no op */ }
override fun onStakeBannerClick() { /* no op */ }
@ -126,6 +138,10 @@ internal class EmptyTokenDetailsClickIntents : TokenDetailsClickIntents {
override fun onSwapClick(unavailabilityReason: ScenarioUnavailabilityReason) { /* no op */ }
override fun onSwapFromClick(unavailabilityReason: ScenarioUnavailabilityReason) { /* no op */ }
override fun onSwapToClick(unavailabilityReason: ScenarioUnavailabilityReason) { /* no op */ }
override fun onHideClick() { /* no op */ }
override fun onHideConfirmed() { /* no op */ }

View file

@ -90,13 +90,21 @@ import com.tangem.feature.tokendetails.presentation.router.InnerTokenDetailsRout
import com.tangem.feature.tokendetails.presentation.tokendetails.analytics.TokenDetailsCurrencyStatusAnalyticsSender
import com.tangem.feature.tokendetails.presentation.tokendetails.analytics.TokenDetailsNotificationsAnalyticsSender
import com.tangem.feature.tokendetails.presentation.tokendetails.route.TokenDetailsBottomSheetConfig
import com.tangem.feature.tokendetails.presentation.tokendetails.state.AddFundsUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceSegmentedButtonConfig
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsStateController
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TransferUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.TokenDetailsStateFactory
import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.InitializeWithCryptoCurrencyTransformer
import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.SetBalanceTransformer
import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.UpdateActionButtonsTransformer
import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.UpdateAddFundsTransformer
import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.UpdateTransferTransformer
import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.UpdateZeroBalanceActionsTransformer
import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.BindAddFundsActionButtonTransformer
import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.BindTransferActionButtonTransformer
import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.SetTopBarTitleTransformer
import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.ToggleBalanceTypeTransformer
import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.UpdateStakingNotificationTransformer
@ -217,6 +225,24 @@ internal class TokenDetailsModel @Inject constructor(
val redesignUiState: StateFlow<TokenDetailsUM> get() = redesignStateController.uiState
val addFundsUiState: StateFlow<AddFundsUM>
field = redesignStateController.uiState
.map { it.addFundsUM }
.stateIn(
scope = modelScope,
started = SharingStarted.Eagerly,
initialValue = redesignStateController.value.addFundsUM,
)
val transferUiState: StateFlow<TransferUM>
field = redesignStateController.uiState
.map { it.transferUM }
.stateIn(
scope = modelScope,
started = SharingStarted.Eagerly,
initialValue = redesignStateController.value.transferUM,
)
// region Clore migration
// TODO: Remove after Clore migration ends ([REDACTED_TASK_KEY])
val cloreMigrationModel by lazy(mode = LazyThreadSafetyMode.NONE) {
@ -313,6 +339,34 @@ internal class TokenDetailsModel @Inject constructor(
.onEach { state ->
sendButtonsEvents(state.states)
uiState.value = stateFactory.getManageButtonsState(actions = state.states)
if (designFeatureToggles.isRedesignEnabled) {
redesignStateController.update(
UpdateActionButtonsTransformer(
actions = state.states,
clickIntents = this@TokenDetailsModel,
),
)
redesignStateController.update(
UpdateAddFundsTransformer(
actions = state.states,
clickIntents = this@TokenDetailsModel,
onActionDispatched = bottomSheetNavigation::dismiss,
),
)
redesignStateController.update(
UpdateTransferTransformer(
actions = state.states,
clickIntents = this@TokenDetailsModel,
onActionDispatched = bottomSheetNavigation::dismiss,
),
)
redesignStateController.update(
UpdateZeroBalanceActionsTransformer(
actions = state.states,
clickIntents = this@TokenDetailsModel,
),
)
}
}
.flowOn(dispatchers.main)
.launchIn(modelScope)
@ -477,6 +531,14 @@ internal class TokenDetailsModel @Inject constructor(
router.popBackStack()
}
override fun onAddFundsClick() {
bottomSheetNavigation.activate(TokenDetailsBottomSheetConfig.AddFunds)
}
override fun onTransferClick() {
bottomSheetNavigation.activate(TokenDetailsBottomSheetConfig.Transfer)
}
override fun onBuyClick(unavailabilityReason: ScenarioUnavailabilityReason) {
analyticsEventsHandler.send(
TokenScreenAnalyticsEvent.ButtonWithParams.ButtonBuy(
@ -660,6 +722,22 @@ internal class TokenDetailsModel @Inject constructor(
}
override fun onSwapClick(unavailabilityReason: ScenarioUnavailabilityReason) {
handleSwap(unavailabilityReason, AppRoute.Swap.CurrencyPosition.ANY, checkYieldSupply = true)
}
override fun onSwapFromClick(unavailabilityReason: ScenarioUnavailabilityReason) {
handleSwap(unavailabilityReason, AppRoute.Swap.CurrencyPosition.FROM, checkYieldSupply = true)
}
override fun onSwapToClick(unavailabilityReason: ScenarioUnavailabilityReason) {
handleSwap(unavailabilityReason, AppRoute.Swap.CurrencyPosition.TO, checkYieldSupply = false)
}
private fun handleSwap(
unavailabilityReason: ScenarioUnavailabilityReason,
currencyPosition: AppRoute.Swap.CurrencyPosition,
checkYieldSupply: Boolean,
) {
analyticsEventsHandler.send(
TokenScreenAnalyticsEvent.ButtonWithParams.ButtonExchange(
token = cryptoCurrency.symbol,
@ -674,7 +752,7 @@ internal class TokenDetailsModel @Inject constructor(
}
modelScope.launch {
if (needShowYieldSupplyDepositedWarningUseCase(cryptoCurrencyStatus)) {
if (checkYieldSupply && needShowYieldSupplyDepositedWarningUseCase(cryptoCurrencyStatus)) {
bottomSheetNavigation.activate(
configuration = TokenDetailsBottomSheetConfig.YieldSupplyWarning(
cryptoCurrency = cryptoCurrency,
@ -687,6 +765,7 @@ internal class TokenDetailsModel @Inject constructor(
cryptoCurrency = cryptoCurrency,
userWalletId = userWalletId,
screenSource = AnalyticsParam.ScreensSources.Token.value,
currencyPosition = currencyPosition,
),
)
}
@ -1284,6 +1363,13 @@ internal class TokenDetailsModel @Inject constructor(
onRefreshSwipe = ::onRefreshSwipe,
),
)
redesignStateController.update(
BindAddFundsActionButtonTransformer(
onClick = ::onAddFundsClick,
onLongClick = { onCopyAddress() },
),
)
redesignStateController.update(BindTransferActionButtonTransformer(onClick = ::onTransferClick))
}
private fun observeRedesignTopBarTitle() {

View file

@ -30,4 +30,10 @@ sealed class TokenDetailsBottomSheetConfig : Route {
@Serializable
data object DynamicAddresses : TokenDetailsBottomSheetConfig()
@Serializable
data object AddFunds : TokenDetailsBottomSheetConfig()
@Serializable
data object Transfer : TokenDetailsBottomSheetConfig()
}

View file

@ -0,0 +1,34 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
/**
* State of the "Get token" bottom sheet shown after tapping the balance-block "Add funds" button.
*
* Stays [Loading] while [com.tangem.domain.tokens.GetCryptoCurrencyActionsUseCase] hasn't yet
* emitted the action list; the sheet renders a spinner in the tail of each row. Once actions
* arrive the state becomes [Content]; unavailable actions stay visible but with
* [Row.isEnabled] = false. Rows whose action is absent from the response are dropped (null).
*/
@Immutable
internal sealed interface AddFundsUM : TangemBottomSheetConfigContent {
@Immutable
data object Loading : AddFundsUM
@Immutable
data class Content(
val buy: Row?,
val swap: Row?,
val receive: Row?,
) : AddFundsUM
@Immutable
data class Row(
val isLoading: Boolean,
val isEnabled: Boolean,
val onClick: () -> Unit,
val onLongClick: (() -> Unit)? = null,
)
}

View file

@ -11,18 +11,24 @@ import kotlinx.collections.immutable.ImmutableList
@Immutable
internal sealed class TokenDetailsBalanceBlockUM {
abstract val actionButtons: ImmutableList<TangemButtonUM>
abstract val addFundsButton: TangemButtonUM
abstract val swapButton: TangemButtonUM
abstract val transferButton: TangemButtonUM
abstract val tokenBalanceTypeUM: TokenBalanceTypeUM
abstract val currencyIconState: CurrencyIconState
data class Loading(
override val actionButtons: ImmutableList<TangemButtonUM>,
override val addFundsButton: TangemButtonUM,
override val swapButton: TangemButtonUM,
override val transferButton: TangemButtonUM,
override val tokenBalanceTypeUM: TokenBalanceTypeUM,
override val currencyIconState: CurrencyIconState,
) : TokenDetailsBalanceBlockUM()
data class Content(
override val actionButtons: ImmutableList<TangemButtonUM>,
override val addFundsButton: TangemButtonUM,
override val swapButton: TangemButtonUM,
override val transferButton: TangemButtonUM,
override val tokenBalanceTypeUM: TokenBalanceTypeUM,
override val currencyIconState: CurrencyIconState,
val displayCryptoBalanceAll: TextReference,
@ -30,6 +36,7 @@ internal sealed class TokenDetailsBalanceBlockUM {
val displayCryptoBalanceAvailable: TextReference?,
val displayFiatBalanceAvailable: TextReference?,
val isBalanceFlickering: Boolean,
val isBalanceZero: Boolean,
) : TokenDetailsBalanceBlockUM() {
val displayCryptoBalance: TextReference
@ -46,7 +53,9 @@ internal sealed class TokenDetailsBalanceBlockUM {
}
data class Error(
override val actionButtons: ImmutableList<TangemButtonUM>,
override val addFundsButton: TangemButtonUM,
override val swapButton: TangemButtonUM,
override val transferButton: TangemButtonUM,
override val tokenBalanceTypeUM: TokenBalanceTypeUM,
override val currencyIconState: CurrencyIconState,
) : TokenDetailsBalanceBlockUM()
@ -58,6 +67,28 @@ internal sealed class TokenDetailsBalanceBlockUM {
is Loading -> this.copy(currencyIconState = iconState)
}
}
fun copyButtons(
addFundsButton: TangemButtonUM = this.addFundsButton,
swapButton: TangemButtonUM = this.swapButton,
transferButton: TangemButtonUM = this.transferButton,
): TokenDetailsBalanceBlockUM = when (this) {
is Content -> copy(
addFundsButton = addFundsButton,
swapButton = swapButton,
transferButton = transferButton,
)
is Error -> copy(
addFundsButton = addFundsButton,
swapButton = swapButton,
transferButton = transferButton,
)
is Loading -> copy(
addFundsButton = addFundsButton,
swapButton = swapButton,
transferButton = transferButton,
)
}
}
internal sealed class TokenBalanceTypeUM {

View file

@ -9,6 +9,7 @@ import com.tangem.core.ui.ds.button.TangemButtonUM
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.tokendetails.impl.R
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.persistentListOf
@ -42,21 +43,29 @@ internal class TokenDetailsStateController @Inject constructor() {
menuItems = persistentListOf(),
),
balanceBlockUM = TokenDetailsBalanceBlockUM.Loading(
actionButtons = persistentListOf(
TangemButtonUM(
text = resourceReference(R.string.tangempay_card_details_add_funds),
tangemIconUM = TangemIconUM.Icon(iconRes = R.drawable.ic_arrow_down_24),
onClick = { },
isEnabled = true,
type = TangemButtonType.Secondary,
),
TangemButtonUM(
text = resourceReference(R.string.common_transfer),
tangemIconUM = TangemIconUM.Icon(iconRes = R.drawable.ic_arrow_up_24),
onClick = { },
isEnabled = true,
type = TangemButtonType.Secondary,
addFundsButton = TangemButtonUM(
text = resourceReference(R.string.tangempay_card_details_add_funds),
tangemIconUM = TangemIconUM.Icon(iconRes = R.drawable.ic_arrow_down_24),
onClick = { },
isEnabled = true,
type = TangemButtonType.Secondary,
),
swapButton = TangemButtonUM(
text = resourceReference(R.string.common_swap),
tangemIconUM = TangemIconUM.Icon(
iconRes = R.drawable.ic_exchange_default_24,
tintReference = { TangemTheme.colors2.graphic.neutral.quaternary },
),
onClick = { },
isEnabled = false,
type = TangemButtonType.Secondary,
),
transferButton = TangemButtonUM(
text = resourceReference(R.string.common_transfer),
tangemIconUM = TangemIconUM.Icon(iconRes = R.drawable.ic_arrow_up_24),
onClick = { },
isEnabled = true,
type = TangemButtonType.Secondary,
),
tokenBalanceTypeUM = TokenBalanceTypeUM.Single,
currencyIconState = CurrencyIconState.Loading,
@ -70,6 +79,9 @@ internal class TokenDetailsStateController @Inject constructor() {
),
isBalanceHidden = false,
isMarketPriceAvailable = false,
addFundsUM = AddFundsUM.Loading,
transferUM = TransferUM.Loading,
zeroBalanceActionsUM = ZeroBalanceActionsUM.Loading,
)
}
}

View file

@ -21,6 +21,9 @@ internal data class TokenDetailsUM(
val pullToRefreshConfig: PullToRefreshConfig,
val isBalanceHidden: Boolean,
val isMarketPriceAvailable: Boolean,
val addFundsUM: AddFundsUM,
val transferUM: TransferUM,
val zeroBalanceActionsUM: ZeroBalanceActionsUM,
)
@Immutable

View file

@ -0,0 +1,33 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
/**
* State of the "Transfer" bottom sheet shown after tapping the balance-block "Transfer" button.
*
* Stays [Loading] while [com.tangem.domain.tokens.GetCryptoCurrencyActionsUseCase] hasn't yet
* emitted the action list; the sheet renders a spinner in the tail of each row. Once actions
* arrive the state becomes [Content]; unavailable actions stay visible but with
* [Row.isEnabled] = false. Rows whose action is absent from the response are dropped (null).
*/
@Immutable
internal sealed interface TransferUM : TangemBottomSheetConfigContent {
@Immutable
data object Loading : TransferUM
@Immutable
data class Content(
val send: Row?,
val swap: Row?,
val sell: Row?,
) : TransferUM
@Immutable
data class Row(
val isLoading: Boolean,
val isEnabled: Boolean,
val onClick: () -> Unit,
)
}

View file

@ -0,0 +1,34 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state
import androidx.compose.runtime.Immutable
/**
* State of the Buy / Swap / Receive rows rendered in place of the balance-block action buttons
* when the token balance is zero.
*
* Stays [Loading] while [com.tangem.domain.tokens.GetCryptoCurrencyActionsUseCase] hasn't yet
* emitted the action list. Once actions arrive the state becomes [Content], and each row
* carries [Row.isEnabled] reflecting its current `ScenarioUnavailabilityReason`. Disabled rows
* stay visible but ignore clicks.
*/
@Immutable
internal sealed interface ZeroBalanceActionsUM {
@Immutable
data object Loading : ZeroBalanceActionsUM
@Immutable
data class Content(
val buy: Row?,
val swap: Row?,
val receive: Row?,
) : ZeroBalanceActionsUM
@Immutable
data class Row(
val isLoading: Boolean,
val isEnabled: Boolean,
val onClick: () -> Unit,
val onLongClick: (() -> Unit)? = null,
)
}

View file

@ -0,0 +1,23 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM
import com.tangem.utils.transformer.Transformer
/**
* Wires the balance block's "Add funds" button click handlers to the provided actions.
*
* [TokenDetailsStateController.getInitialState] sets up the button without click handlers
* because the controller can't see [TokenDetailsClickIntents]; this transformer fills them in
* once the model is constructed.
*/
internal class BindAddFundsActionButtonTransformer(
private val onClick: () -> Unit,
private val onLongClick: () -> Unit,
) : Transformer<TokenDetailsUM> {
override fun transform(prevState: TokenDetailsUM): TokenDetailsUM {
val prev = prevState.balanceBlockUM
val updated = prev.addFundsButton.copy(onClick = onClick, onLongClick = onLongClick)
return prevState.copy(balanceBlockUM = prev.copyButtons(addFundsButton = updated))
}
}

View file

@ -0,0 +1,20 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM
import com.tangem.utils.transformer.Transformer
/**
* Wires the balance block's "Transfer" button onClick to the provided action.
*
* See [BindAddFundsActionButtonTransformer] for the same pattern used for the "Add funds" button.
*/
internal class BindTransferActionButtonTransformer(
private val onClick: () -> Unit,
) : Transformer<TokenDetailsUM> {
override fun transform(prevState: TokenDetailsUM): TokenDetailsUM {
val prev = prevState.balanceBlockUM
val updated = prev.transferButton.copy(onClick = onClick)
return prevState.copy(balanceBlockUM = prev.copyButtons(transferButton = updated))
}
}

View file

@ -14,7 +14,9 @@ internal class SetBalanceLoadingTransformer(
val prevBalance = prevState.balanceBlockUM
return prevState.copy(
balanceBlockUM = TokenDetailsBalanceBlockUM.Loading(
actionButtons = prevBalance.actionButtons,
addFundsButton = prevBalance.addFundsButton,
swapButton = prevBalance.swapButton,
transferButton = prevBalance.transferButton,
tokenBalanceTypeUM = TokenBalanceTypeUM.Single,
currencyIconState = currencyIconState,
),

View file

@ -40,7 +40,9 @@ internal class SetBalanceTransformer(
val prev = prevState.balanceBlockUM
val balanceBlockUM = when (status.value) {
is CryptoCurrencyStatus.Loading -> TokenDetailsBalanceBlockUM.Loading(
actionButtons = prev.actionButtons,
addFundsButton = prev.addFundsButton,
swapButton = prev.swapButton,
transferButton = prev.transferButton,
tokenBalanceTypeUM = prev.tokenBalanceTypeUM,
currencyIconState = prev.currencyIconState,
)
@ -53,7 +55,9 @@ internal class SetBalanceTransformer(
is CryptoCurrencyStatus.Unreachable,
is CryptoCurrencyStatus.NoAmount,
-> TokenDetailsBalanceBlockUM.Error(
actionButtons = prev.actionButtons,
addFundsButton = prev.addFundsButton,
swapButton = prev.swapButton,
transferButton = prev.transferButton,
tokenBalanceTypeUM = prev.tokenBalanceTypeUM,
currencyIconState = prev.currencyIconState,
)
@ -80,16 +84,18 @@ internal class SetBalanceTransformer(
TokenBalanceTypeUM.Single
}
val totalCryptoAmount = computeTotal(status.value.amount, stakingCryptoAmount)
return TokenDetailsBalanceBlockUM.Content(
actionButtons = prev.actionButtons,
addFundsButton = prev.addFundsButton,
swapButton = prev.swapButton,
transferButton = prev.transferButton,
currencyIconState = prev.currencyIconState,
tokenBalanceTypeUM = tokenBalanceTypeUM,
displayFiatBalanceAll = formatFiatStyled(
fiatAmount = computeTotal(status.value.fiatAmount, stakingFiatAmount),
),
displayCryptoBalanceAll = formatCrypto(
amount = computeTotal(status.value.amount, stakingCryptoAmount),
),
displayCryptoBalanceAll = formatCrypto(amount = totalCryptoAmount),
displayFiatBalanceAvailable = if (hasStaking) {
formatFiatStyled(fiatAmount = status.value.fiatAmount)
} else {
@ -101,6 +107,7 @@ internal class SetBalanceTransformer(
null
},
isBalanceFlickering = status.value.sources.total == StatusSource.CACHE,
isBalanceZero = totalCryptoAmount.isNullOrZero(),
)
}

View file

@ -0,0 +1,39 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.res.TangemTheme
import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason
import com.tangem.domain.tokens.model.TokenActionsState
import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM
import com.tangem.utils.transformer.Transformer
internal class UpdateActionButtonsTransformer(
private val actions: List<TokenActionsState.ActionState>,
private val clickIntents: TokenDetailsClickIntents,
) : Transformer<TokenDetailsUM> {
override fun transform(prevState: TokenDetailsUM): TokenDetailsUM {
val swapAction = actions.firstNotNullOfOrNull { it as? TokenActionsState.ActionState.Swap }
?: return prevState
val prev = prevState.balanceBlockUM
val isSwapEnabled = swapAction.unavailabilityReason == ScenarioUnavailabilityReason.None
val updated = prev.swapButton.copy(
isEnabled = isSwapEnabled,
tangemIconUM = (prev.swapButton.tangemIconUM as? TangemIconUM.Icon)?.copy(
tint = {
if (isSwapEnabled) {
TangemTheme.colors2.graphic.neutral.primary
} else {
TangemTheme.colors2.graphic.neutral.quaternary
}
},
) ?: prev.swapButton.tangemIconUM,
onClick = { clickIntents.onSwapFromClick(swapAction.unavailabilityReason) },
)
return prevState.copy(balanceBlockUM = prev.copyButtons(swapButton = updated))
}
}

View file

@ -0,0 +1,63 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer
import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason
import com.tangem.domain.tokens.model.TokenActionsState
import com.tangem.domain.tokens.model.isLoading
import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents
import com.tangem.feature.tokendetails.presentation.tokendetails.state.AddFundsUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM
import com.tangem.utils.transformer.Transformer
internal class UpdateAddFundsTransformer(
private val actions: List<TokenActionsState.ActionState>,
private val clickIntents: TokenDetailsClickIntents,
private val onActionDispatched: () -> Unit,
) : Transformer<TokenDetailsUM> {
override fun transform(prevState: TokenDetailsUM): TokenDetailsUM {
val buyAction = actions.firstNotNullOfOrNull { it as? TokenActionsState.ActionState.Buy }
val swapAction = actions.firstNotNullOfOrNull { it as? TokenActionsState.ActionState.Swap }
val receiveAction = actions.firstNotNullOfOrNull { it as? TokenActionsState.ActionState.Receive }
if (buyAction == null && swapAction == null && receiveAction == null) return prevState
val buyRow = buyAction?.let { action ->
AddFundsUM.Row(
isLoading = action.unavailabilityReason.isLoading,
isEnabled = action.unavailabilityReason == ScenarioUnavailabilityReason.None,
onClick = {
onActionDispatched()
clickIntents.onBuyClick(action.unavailabilityReason)
},
)
}
val swapRow = swapAction?.let { action ->
AddFundsUM.Row(
isLoading = action.unavailabilityReason.isLoading,
isEnabled = action.unavailabilityReason == ScenarioUnavailabilityReason.None,
onClick = {
onActionDispatched()
clickIntents.onSwapToClick(action.unavailabilityReason)
},
)
}
val receiveRow = receiveAction?.let { action ->
AddFundsUM.Row(
isLoading = action.unavailabilityReason.isLoading,
isEnabled = action.unavailabilityReason == ScenarioUnavailabilityReason.None,
onClick = {
onActionDispatched()
clickIntents.onReceiveClick(action.unavailabilityReason)
},
onLongClick = {
onActionDispatched()
clickIntents.onCopyAddress()
},
)
}
return prevState.copy(
addFundsUM = AddFundsUM.Content(buy = buyRow, swap = swapRow, receive = receiveRow),
)
}
}

View file

@ -0,0 +1,59 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer
import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason
import com.tangem.domain.tokens.model.TokenActionsState
import com.tangem.domain.tokens.model.isLoading
import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TransferUM
import com.tangem.utils.transformer.Transformer
internal class UpdateTransferTransformer(
private val actions: List<TokenActionsState.ActionState>,
private val clickIntents: TokenDetailsClickIntents,
private val onActionDispatched: () -> Unit,
) : Transformer<TokenDetailsUM> {
override fun transform(prevState: TokenDetailsUM): TokenDetailsUM {
val sendAction = actions.firstNotNullOfOrNull { it as? TokenActionsState.ActionState.Send }
val swapAction = actions.firstNotNullOfOrNull { it as? TokenActionsState.ActionState.Swap }
val sellAction = actions.firstNotNullOfOrNull { it as? TokenActionsState.ActionState.Sell }
if (sendAction == null && swapAction == null && sellAction == null) return prevState
val sendRow = sendAction?.let { action ->
TransferUM.Row(
isLoading = action.unavailabilityReason.isLoading,
isEnabled = action.unavailabilityReason == ScenarioUnavailabilityReason.None,
onClick = {
onActionDispatched()
clickIntents.onSendClick(action.unavailabilityReason)
},
)
}
val swapRow = swapAction?.let { action ->
TransferUM.Row(
isLoading = action.unavailabilityReason.isLoading,
isEnabled = action.unavailabilityReason == ScenarioUnavailabilityReason.None,
onClick = {
onActionDispatched()
clickIntents.onSwapFromClick(action.unavailabilityReason)
},
)
}
val sellRow = sellAction?.let { action ->
TransferUM.Row(
isLoading = action.unavailabilityReason.isLoading,
isEnabled = action.unavailabilityReason == ScenarioUnavailabilityReason.None,
onClick = {
onActionDispatched()
clickIntents.onSellClick(action.unavailabilityReason)
},
)
}
return prevState.copy(
transferUM = TransferUM.Content(send = sendRow, swap = swapRow, sell = sellRow),
)
}
}

View file

@ -0,0 +1,47 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer
import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason
import com.tangem.domain.tokens.model.TokenActionsState
import com.tangem.domain.tokens.model.isLoading
import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.ZeroBalanceActionsUM
import com.tangem.utils.transformer.Transformer
internal class UpdateZeroBalanceActionsTransformer(
private val actions: List<TokenActionsState.ActionState>,
private val clickIntents: TokenDetailsClickIntents,
) : Transformer<TokenDetailsUM> {
override fun transform(prevState: TokenDetailsUM): TokenDetailsUM {
val buyAction = actions.firstNotNullOfOrNull { it as? TokenActionsState.ActionState.Buy }
val swapAction = actions.firstNotNullOfOrNull { it as? TokenActionsState.ActionState.Swap }
val receiveAction = actions.firstNotNullOfOrNull { it as? TokenActionsState.ActionState.Receive }
if (buyAction == null && swapAction == null && receiveAction == null) return prevState
return prevState.copy(
zeroBalanceActionsUM = ZeroBalanceActionsUM.Content(
buy = buyAction?.toRow(onClick = clickIntents::onBuyClick),
swap = swapAction?.toRow(onClick = clickIntents::onSwapToClick),
receive = receiveAction?.toRow(
onClick = clickIntents::onReceiveClick,
onLongClick = { clickIntents.onCopyAddress() },
),
),
)
}
private fun TokenActionsState.ActionState.toRow(
onClick: (ScenarioUnavailabilityReason) -> Unit,
onLongClick: (() -> Unit)? = null,
): ZeroBalanceActionsUM.Row {
val reason = unavailabilityReason
return ZeroBalanceActionsUM.Row(
isLoading = reason.isLoading,
isEnabled = reason == ScenarioUnavailabilityReason.None,
onClick = { onClick(reason) },
onLongClick = onLongClick,
)
}
}

View file

@ -36,6 +36,8 @@ import com.tangem.common.ui.expressStatus.state.ExpressTransactionStateUM
import com.tangem.common.ui.expressStatus.state.ExpressTransactionsBlockState
import com.tangem.core.ui.components.BottomFade
import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig
import com.tangem.core.ui.ds.button.TangemButtonType
import com.tangem.core.ui.ds.button.TangemButtonUM
import com.tangem.core.ui.components.containers.pullToRefresh.TangemPullToRefreshSlidingContainer
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenuItem
@ -47,12 +49,16 @@ import com.tangem.core.ui.res.LocalRootBackgroundColor
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.feature.tokendetails.presentation.tokendetails.state.AddFundsUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceTypeUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TransferUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM.TitleState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.ZeroBalanceActionsUM
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.TokenDetailsBalanceBlock
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.ZeroBalanceActionsBlock
import com.tangem.features.markets.token.block.TokenMarketBlockComponent
import com.tangem.features.tokendetails.ExpressTransactionsComponent
import com.tangem.features.txhistory.component.TxHistoryComponent
@ -194,6 +200,15 @@ private fun TokenDetailsBody(
modifier = Modifier.fillMaxWidth(),
)
}
val balance = tokenDetailsUM.balanceBlockUM
if (balance is TokenDetailsBalanceBlockUM.Content && balance.isBalanceZero) {
item(key = "zero_balance_actions") {
ZeroBalanceActionsBlock(
state = tokenDetailsUM.zeroBalanceActionsUM,
modifier = itemModifier,
)
}
}
notifications(
notifications = tokenDetailsUM.notifications,
contentColor = rootBackground,
@ -253,7 +268,9 @@ private fun TokenDetailsScreen_Preview() {
notifications = persistentListOf(),
earnBlockState = null,
balanceBlockUM = TokenDetailsBalanceBlockUM.Loading(
actionButtons = persistentListOf(),
addFundsButton = previewActionButton(),
swapButton = previewActionButton(),
transferButton = previewActionButton(),
tokenBalanceTypeUM = TokenBalanceTypeUM.Single,
currencyIconState = CurrencyIconState.Loading,
),
@ -264,6 +281,9 @@ private fun TokenDetailsScreen_Preview() {
),
isBalanceHidden = false,
isMarketPriceAvailable = true,
addFundsUM = AddFundsUM.Loading,
transferUM = TransferUM.Loading,
zeroBalanceActionsUM = ZeroBalanceActionsUM.Loading,
),
yieldSupplyComponent = object : YieldSupplyComponent {
@Composable
@ -287,6 +307,13 @@ private fun TokenDetailsScreen_Preview() {
}
}
private fun previewActionButton(): TangemButtonUM = TangemButtonUM(
text = stringReference(""),
onClick = { },
isEnabled = true,
type = TangemButtonType.Secondary,
)
private val PreviewExpressTransactionsComponent = object : ExpressTransactionsComponent {
override val state: StateFlow<ExpressTransactionsBlockState> = MutableStateFlow(
ExpressTransactionsBlockState(

View file

@ -0,0 +1,59 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheet
import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.feature.tokendetails.presentation.tokendetails.state.AddFundsUM
import kotlinx.coroutines.flow.StateFlow
import com.tangem.core.ui.R as CoreR
internal class AddFundsBottomSheetComponent(
private val stateFlow: StateFlow<AddFundsUM>,
private val onDismiss: () -> Unit,
) : ComposableBottomSheetComponent {
override fun dismiss() {
onDismiss()
}
@Composable
override fun BottomSheet() {
val state by stateFlow.collectAsStateWithLifecycle()
val config = remember(state) {
TangemBottomSheetConfig(
isShown = true,
onDismissRequest = ::dismiss,
content = state,
)
}
TangemModalBottomSheet<AddFundsUM>(
config = config,
containerColor = TangemTheme.colors2.surface.level2,
title = {
TangemModalBottomSheetTitle(
title = resourceReference(CoreR.string.common_get_token),
endIconRes = CoreR.drawable.ic_close_24,
onEndClick = ::dismiss,
)
},
content = { contentState ->
AddFundsBottomSheetContent(
state = contentState,
onCloseClick = ::dismiss,
modifier = Modifier.padding(horizontal = TangemTheme.dimens2.x4),
)
},
)
}
}

View file

@ -0,0 +1,165 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import com.tangem.common.ui.tokenaction.TokenActionRow
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.ds.button.SecondaryTangemButton
import com.tangem.core.ui.ds.button.TangemButtonShape
import com.tangem.core.ui.ds.button.TangemButtonSize
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.LocalHazeState
import com.tangem.core.ui.res.LocalRedesignEnabled
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.feature.tokendetails.presentation.tokendetails.state.AddFundsUM
import dev.chrisbanes.haze.rememberHazeState
import com.tangem.core.ui.R as CoreR
@Composable
internal fun AddFundsBottomSheetContent(state: AddFundsUM, onCloseClick: () -> Unit, modifier: Modifier = Modifier) {
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2),
) {
BuyActionRow(state = state)
SwapActionRow(state = state)
ReceiveActionRow(state = state)
SpacerH(TangemTheme.dimens2.x2)
CompositionLocalProvider(LocalHazeState provides rememberHazeState()) {
SecondaryTangemButton(
modifier = Modifier.fillMaxWidth(),
onClick = onCloseClick,
text = resourceReference(CoreR.string.common_close),
size = TangemButtonSize.X12,
shape = TangemButtonShape.Rounded,
)
}
}
}
@Composable
private fun BuyActionRow(state: AddFundsUM) {
val row = (state as? AddFundsUM.Content)?.buy
if (state is AddFundsUM.Content && row == null) return
ActionRow(
iconRes = CoreR.drawable.ic_credit_card_20,
title = resourceReference(CoreR.string.common_buy),
description = resourceReference(CoreR.string.quick_action_buy_description),
row = row,
isLoading = state is AddFundsUM.Loading,
)
}
@Composable
private fun SwapActionRow(state: AddFundsUM) {
val row = (state as? AddFundsUM.Content)?.swap
if (state is AddFundsUM.Content && row == null) return
ActionRow(
iconRes = CoreR.drawable.ic_exchange_mini_24,
title = resourceReference(CoreR.string.common_swap),
description = resourceReference(CoreR.string.quick_action_swap_description),
row = row,
isLoading = state is AddFundsUM.Loading,
)
}
@Composable
private fun ReceiveActionRow(state: AddFundsUM) {
val row = (state as? AddFundsUM.Content)?.receive
if (state is AddFundsUM.Content && row == null) return
ActionRow(
iconRes = CoreR.drawable.ic_qrcode_new_24,
title = resourceReference(CoreR.string.common_receive),
description = resourceReference(CoreR.string.quick_action_receive_description),
row = row,
isLoading = state is AddFundsUM.Loading,
)
}
@Composable
private fun ActionRow(
iconRes: Int,
title: TextReference,
description: TextReference,
row: AddFundsUM.Row?,
isLoading: Boolean,
) {
if (isLoading || row?.isLoading == true) {
TokenActionRow(
iconRes = iconRes,
title = title,
description = description,
tailContent = { TailLoader() },
)
} else {
TokenActionRow(
iconRes = iconRes,
title = title,
description = description,
onClick = row?.onClick,
onLongClick = row?.onLongClick,
isEnabled = row?.isEnabled == true,
)
}
}
@Composable
private fun TailLoader() {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = TangemTheme.colors2.graphic.neutral.tertiary,
strokeWidth = 2.dp,
)
}
// region Preview
@Preview(widthDp = 360, showBackground = true)
@Composable
private fun Preview(@PreviewParameter(AddFundsPreviewProvider::class) state: AddFundsUM) {
TangemThemePreviewRedesign {
CompositionLocalProvider(LocalRedesignEnabled provides true) {
AddFundsBottomSheetContent(
state = state,
onCloseClick = {},
modifier = Modifier.padding(horizontal = 16.dp),
)
}
}
}
private class AddFundsPreviewProvider : PreviewParameterProvider<AddFundsUM> {
override val values: Sequence<AddFundsUM> = sequenceOf(
AddFundsUM.Loading,
AddFundsUM.Content(
buy = AddFundsUM.Row(isLoading = false, isEnabled = true, onClick = {}),
swap = AddFundsUM.Row(isLoading = false, isEnabled = true, onClick = {}),
receive = AddFundsUM.Row(isLoading = false, isEnabled = true, onClick = {}, onLongClick = {}),
),
AddFundsUM.Content(
buy = AddFundsUM.Row(isLoading = false, isEnabled = false, onClick = {}),
swap = AddFundsUM.Row(isLoading = false, isEnabled = false, onClick = {}),
receive = AddFundsUM.Row(isLoading = false, isEnabled = true, onClick = {}, onLongClick = {}),
),
AddFundsUM.Content(
buy = null,
swap = null,
receive = AddFundsUM.Row(isLoading = false, isEnabled = true, onClick = {}, onLongClick = {}),
),
)
}
// endregion

View file

@ -0,0 +1,59 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheet
import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TransferUM
import kotlinx.coroutines.flow.StateFlow
import com.tangem.core.ui.R as CoreR
internal class TransferBottomSheetComponent(
private val stateFlow: StateFlow<TransferUM>,
private val onDismiss: () -> Unit,
) : ComposableBottomSheetComponent {
override fun dismiss() {
onDismiss()
}
@Composable
override fun BottomSheet() {
val state by stateFlow.collectAsStateWithLifecycle()
val config = remember(state) {
TangemBottomSheetConfig(
isShown = true,
onDismissRequest = ::dismiss,
content = state,
)
}
TangemModalBottomSheet<TransferUM>(
config = config,
containerColor = TangemTheme.colors2.surface.level2,
title = {
TangemModalBottomSheetTitle(
title = resourceReference(CoreR.string.common_transfer),
endIconRes = CoreR.drawable.ic_close_24,
onEndClick = ::dismiss,
)
},
content = { contentState ->
TransferBottomSheetContent(
state = contentState,
onCloseClick = ::dismiss,
modifier = Modifier.padding(horizontal = TangemTheme.dimens2.x4),
)
},
)
}
}

View file

@ -0,0 +1,164 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import com.tangem.common.ui.tokenaction.TokenActionRow
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.ds.button.SecondaryTangemButton
import com.tangem.core.ui.ds.button.TangemButtonShape
import com.tangem.core.ui.ds.button.TangemButtonSize
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.LocalHazeState
import com.tangem.core.ui.res.LocalRedesignEnabled
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TransferUM
import dev.chrisbanes.haze.rememberHazeState
import com.tangem.core.ui.R as CoreR
@Composable
internal fun TransferBottomSheetContent(state: TransferUM, onCloseClick: () -> Unit, modifier: Modifier = Modifier) {
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2),
) {
SendActionRow(state = state)
SwapActionRow(state = state)
SellActionRow(state = state)
SpacerH(TangemTheme.dimens2.x2)
CompositionLocalProvider(LocalHazeState provides rememberHazeState()) {
SecondaryTangemButton(
modifier = Modifier.fillMaxWidth(),
onClick = onCloseClick,
text = resourceReference(CoreR.string.common_close),
size = TangemButtonSize.X12,
shape = TangemButtonShape.Rounded,
)
}
}
}
@Composable
private fun SendActionRow(state: TransferUM) {
val row = (state as? TransferUM.Content)?.send
if (state is TransferUM.Content && row == null) return
ActionRow(
iconRes = CoreR.drawable.ic_arrow_up_24,
title = resourceReference(CoreR.string.common_send),
description = resourceReference(CoreR.string.quick_action_send_description),
row = row,
isLoading = state is TransferUM.Loading,
)
}
@Composable
private fun SwapActionRow(state: TransferUM) {
val row = (state as? TransferUM.Content)?.swap
if (state is TransferUM.Content && row == null) return
ActionRow(
iconRes = CoreR.drawable.ic_exchange_mini_24,
title = resourceReference(CoreR.string.common_swap),
description = resourceReference(CoreR.string.quick_action_swap_description),
row = row,
isLoading = state is TransferUM.Loading,
)
}
@Composable
private fun SellActionRow(state: TransferUM) {
val row = (state as? TransferUM.Content)?.sell
if (state is TransferUM.Content && row == null) return
ActionRow(
iconRes = CoreR.drawable.ic_currency_24,
title = resourceReference(CoreR.string.common_sell),
description = resourceReference(CoreR.string.quick_action_sell_description),
row = row,
isLoading = state is TransferUM.Loading,
)
}
@Composable
private fun ActionRow(
iconRes: Int,
title: TextReference,
description: TextReference,
row: TransferUM.Row?,
isLoading: Boolean,
) {
if (isLoading || row?.isLoading == true) {
TokenActionRow(
iconRes = iconRes,
title = title,
description = description,
tailContent = { TailLoader() },
)
} else {
TokenActionRow(
iconRes = iconRes,
title = title,
description = description,
onClick = row?.onClick,
isEnabled = row?.isEnabled == true,
)
}
}
@Composable
private fun TailLoader() {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = TangemTheme.colors2.graphic.neutral.tertiary,
strokeWidth = 2.dp,
)
}
// region Preview
@Preview(widthDp = 360, showBackground = true)
@Composable
private fun Preview(@PreviewParameter(TransferPreviewProvider::class) state: TransferUM) {
TangemThemePreviewRedesign {
CompositionLocalProvider(LocalRedesignEnabled provides true) {
TransferBottomSheetContent(
state = state,
onCloseClick = {},
modifier = Modifier.padding(horizontal = 16.dp),
)
}
}
}
private class TransferPreviewProvider : PreviewParameterProvider<TransferUM> {
override val values: Sequence<TransferUM> = sequenceOf(
TransferUM.Loading,
TransferUM.Content(
send = TransferUM.Row(isLoading = false, isEnabled = true, onClick = {}),
swap = TransferUM.Row(isLoading = false, isEnabled = true, onClick = {}),
sell = TransferUM.Row(isLoading = false, isEnabled = true, onClick = {}),
),
TransferUM.Content(
send = TransferUM.Row(isLoading = false, isEnabled = true, onClick = {}),
swap = TransferUM.Row(isLoading = false, isEnabled = false, onClick = {}),
sell = TransferUM.Row(isLoading = false, isEnabled = false, onClick = {}),
),
TransferUM.Content(
send = TransferUM.Row(isLoading = false, isEnabled = true, onClick = {}),
swap = null,
sell = null,
),
)
}
// endregion

View file

@ -14,6 +14,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
@ -65,11 +66,28 @@ internal fun TokenDetailsBalanceBlock(balanceBlockUM: TokenDetailsBalanceBlockUM
is TokenDetailsBalanceBlockUM.Loading -> LoadingBody()
is TokenDetailsBalanceBlockUM.Error -> ErrorBody()
}
SpacerH(TangemTheme.dimens2.x10)
ActionButtons(buttons = balanceBlockUM.actionButtons)
if (!balanceBlockUM.isBalanceZeroContent()) {
SpacerH(TangemTheme.dimens2.x10)
val buttons = remember(
balanceBlockUM.addFundsButton,
balanceBlockUM.swapButton,
balanceBlockUM.transferButton,
) {
persistentListOf(
balanceBlockUM.addFundsButton,
balanceBlockUM.swapButton,
balanceBlockUM.transferButton,
)
}
ActionButtons(buttons = buttons)
}
}
}
private fun TokenDetailsBalanceBlockUM.isBalanceZeroContent(): Boolean {
return (this as? TokenDetailsBalanceBlockUM.Content)?.isBalanceZero == true
}
@Composable
private fun ContentBody(state: TokenDetailsBalanceBlockUM.Content) {
AnimatedContent(
@ -169,33 +187,45 @@ private fun TokenDetailsBalanceBlock_Preview(
private class PreviewProvider : PreviewParameterProvider<TokenDetailsBalanceBlockUM> {
private val previewActionButtons = persistentListOf(
TangemButtonUM(
text = stringReference("Add funds"),
tangemIconUM = TangemIconUM.Icon(
iconRes = R.drawable.ic_arrow_down_24,
tintReference = { TangemTheme.colors2.graphic.neutral.primary },
),
onClick = { },
isEnabled = true,
type = TangemButtonType.Secondary,
private val previewAddFundsButton = TangemButtonUM(
text = stringReference("Add funds"),
tangemIconUM = TangemIconUM.Icon(
iconRes = R.drawable.ic_arrow_down_24,
tintReference = { TangemTheme.colors2.graphic.neutral.primary },
),
TangemButtonUM(
text = stringReference("Transfer"),
tangemIconUM = TangemIconUM.Icon(
iconRes = R.drawable.ic_arrow_up_24,
tintReference = { TangemTheme.colors2.graphic.neutral.primary },
),
onClick = { },
isEnabled = true,
type = TangemButtonType.Secondary,
onClick = { },
isEnabled = true,
type = TangemButtonType.Secondary,
)
private val previewSwapButton = TangemButtonUM(
text = stringReference("Swap"),
tangemIconUM = TangemIconUM.Icon(
iconRes = R.drawable.ic_exchange_default_24,
tintReference = { TangemTheme.colors2.graphic.neutral.primary },
),
onClick = { },
isEnabled = true,
type = TangemButtonType.Secondary,
)
private val previewTransferButton = TangemButtonUM(
text = stringReference("Transfer"),
tangemIconUM = TangemIconUM.Icon(
iconRes = R.drawable.ic_arrow_up_24,
tintReference = { TangemTheme.colors2.graphic.neutral.primary },
),
onClick = { },
isEnabled = true,
type = TangemButtonType.Secondary,
)
override val values: Sequence<TokenDetailsBalanceBlockUM>
get() = sequenceOf(
TokenDetailsBalanceBlockUM.Content(
actionButtons = previewActionButtons,
addFundsButton = previewAddFundsButton,
swapButton = previewSwapButton,
transferButton = previewTransferButton,
tokenBalanceTypeUM = TokenBalanceTypeUM.Multiple(
type = TokenBalanceTypeUM.Type.ALL,
availableTypes = persistentListOf(
@ -210,9 +240,12 @@ private class PreviewProvider : PreviewParameterProvider<TokenDetailsBalanceBloc
displayCryptoBalanceAvailable = stringReference("0.05 BTC"),
displayFiatBalanceAvailable = stringReference("$10,000.00"),
isBalanceFlickering = false,
isBalanceZero = false,
),
TokenDetailsBalanceBlockUM.Content(
actionButtons = previewActionButtons,
addFundsButton = previewAddFundsButton,
swapButton = previewSwapButton,
transferButton = previewTransferButton,
tokenBalanceTypeUM = TokenBalanceTypeUM.Single,
currencyIconState = CurrencyIconState.Loading,
displayCryptoBalanceAll = stringReference("123.456 USDT"),
@ -220,14 +253,32 @@ private class PreviewProvider : PreviewParameterProvider<TokenDetailsBalanceBloc
displayCryptoBalanceAvailable = null,
displayFiatBalanceAvailable = null,
isBalanceFlickering = false,
isBalanceZero = false,
),
TokenDetailsBalanceBlockUM.Content(
addFundsButton = previewAddFundsButton,
swapButton = previewSwapButton,
transferButton = previewTransferButton,
tokenBalanceTypeUM = TokenBalanceTypeUM.Single,
currencyIconState = CurrencyIconState.Loading,
displayCryptoBalanceAll = stringReference("0 USDT"),
displayFiatBalanceAll = stringReference("$0.00"),
displayCryptoBalanceAvailable = null,
displayFiatBalanceAvailable = null,
isBalanceFlickering = false,
isBalanceZero = true,
),
TokenDetailsBalanceBlockUM.Loading(
actionButtons = previewActionButtons,
addFundsButton = previewAddFundsButton,
swapButton = previewSwapButton,
transferButton = previewTransferButton,
tokenBalanceTypeUM = TokenBalanceTypeUM.Single,
currencyIconState = CurrencyIconState.Loading,
),
TokenDetailsBalanceBlockUM.Error(
actionButtons = previewActionButtons,
addFundsButton = previewAddFundsButton,
swapButton = previewSwapButton,
transferButton = previewTransferButton,
tokenBalanceTypeUM = TokenBalanceTypeUM.Single,
currencyIconState = CurrencyIconState.Loading,
),

View file

@ -0,0 +1,113 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.ui.components
import androidx.annotation.DrawableRes
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import com.tangem.common.ui.tokenaction.TokenActionRow
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.feature.tokendetails.presentation.tokendetails.state.ZeroBalanceActionsUM
import com.tangem.core.ui.R as CoreR
@Composable
internal fun ZeroBalanceActionsBlock(state: ZeroBalanceActionsUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(bottom = TangemTheme.dimens2.x10),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2),
) {
ActionRow(
iconRes = CoreR.drawable.ic_credit_card_20,
title = resourceReference(CoreR.string.common_buy),
description = resourceReference(CoreR.string.quick_action_buy_description),
row = (state as? ZeroBalanceActionsUM.Content)?.buy,
isLoading = state is ZeroBalanceActionsUM.Loading,
)
ActionRow(
iconRes = CoreR.drawable.ic_exchange_mini_24,
title = resourceReference(CoreR.string.common_swap),
description = resourceReference(CoreR.string.quick_action_swap_description),
row = (state as? ZeroBalanceActionsUM.Content)?.swap,
isLoading = state is ZeroBalanceActionsUM.Loading,
)
ActionRow(
iconRes = CoreR.drawable.ic_qrcode_new_24,
title = resourceReference(CoreR.string.common_receive),
description = resourceReference(CoreR.string.quick_action_receive_description),
row = (state as? ZeroBalanceActionsUM.Content)?.receive,
isLoading = state is ZeroBalanceActionsUM.Loading,
)
}
}
@Composable
private fun ActionRow(
@DrawableRes iconRes: Int,
title: TextReference,
description: TextReference,
row: ZeroBalanceActionsUM.Row?,
isLoading: Boolean,
) {
// UM-level Loading (no emission yet) OR per-row Loading (Buy/Swap reason carries Loading
// marker) → spinner. Otherwise chevron tail with `isEnabled` gating clicks.
if (isLoading || row?.isLoading == true) {
TokenActionRow(
iconRes = iconRes,
title = title,
description = description,
tailContent = { TailLoader() },
)
} else {
TokenActionRow(
iconRes = iconRes,
title = title,
description = description,
onClick = row?.onClick,
onLongClick = row?.onLongClick,
isEnabled = row?.isEnabled == true,
)
}
}
@Composable
private fun TailLoader() {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = TangemTheme.colors2.graphic.neutral.tertiary,
strokeWidth = 2.dp,
)
}
// region Preview
@Preview(showBackground = true, widthDp = 360)
@Composable
private fun Preview(@PreviewParameter(ZeroBalanceActionsPreviewProvider::class) state: ZeroBalanceActionsUM) {
TangemThemePreviewRedesign {
ZeroBalanceActionsBlock(state = state)
}
}
private class ZeroBalanceActionsPreviewProvider : PreviewParameterProvider<ZeroBalanceActionsUM> {
override val values: Sequence<ZeroBalanceActionsUM> = sequenceOf(
ZeroBalanceActionsUM.Loading,
ZeroBalanceActionsUM.Content(
buy = ZeroBalanceActionsUM.Row(isLoading = false, isEnabled = true, onClick = {}),
swap = ZeroBalanceActionsUM.Row(isLoading = false, isEnabled = false, onClick = {}),
receive = ZeroBalanceActionsUM.Row(isLoading = false, isEnabled = true, onClick = {}, onLongClick = {}),
),
)
}
// endregion

View file

@ -0,0 +1,138 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer
import com.google.common.truth.Truth.assertThat
import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.marketprice.MarketPriceBlockState
import com.tangem.core.ui.ds.button.TangemButtonType
import com.tangem.core.ui.ds.button.TangemButtonUM
import com.tangem.core.ui.extensions.stringReference
import com.tangem.feature.tokendetails.presentation.tokendetails.state.AddFundsUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceTypeUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM.TitleState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TransferUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.ZeroBalanceActionsUM
import io.mockk.mockk
import io.mockk.verify
import kotlinx.collections.immutable.persistentListOf
import org.junit.jupiter.api.Test
class BindAddFundsActionButtonTransformerTest {
private val onClick: () -> Unit = mockk(relaxed = true)
private val onLongClick: () -> Unit = mockk(relaxed = true)
private val previousAddFundsClick: () -> Unit = mockk(relaxed = true)
private val swapClick: () -> Unit = mockk(relaxed = true)
private val transferClick: () -> Unit = mockk(relaxed = true)
@Test
fun `GIVEN buttons WHEN transform THEN onClick of add-funds button is replaced`() {
// GIVEN
val transformer = BindAddFundsActionButtonTransformer(onClick = onClick, onLongClick = onLongClick)
// WHEN
val result = transformer.transform(stateWithButtons())
result.balanceBlockUM.addFundsButton.onClick()
// THEN
verify(exactly = 1) { onClick.invoke() }
verify(exactly = 0) { previousAddFundsClick.invoke() }
}
@Test
fun `GIVEN buttons WHEN long-click invoked on Add funds THEN onLongClick fires`() {
// GIVEN
val transformer = BindAddFundsActionButtonTransformer(onClick = onClick, onLongClick = onLongClick)
// WHEN
val result = transformer.transform(stateWithButtons())
result.balanceBlockUM.addFundsButton.onLongClick!!()
// THEN
verify(exactly = 1) { onLongClick.invoke() }
verify(exactly = 0) { onClick.invoke() }
}
@Test
fun `GIVEN buttons WHEN transform THEN Swap and Transfer onClick are untouched`() {
// GIVEN
val transformer = BindAddFundsActionButtonTransformer(onClick = onClick, onLongClick = onLongClick)
// WHEN
val result = transformer.transform(stateWithButtons())
result.balanceBlockUM.swapButton.onClick()
result.balanceBlockUM.transferButton.onClick()
// THEN
verify(exactly = 0) { onClick.invoke() }
verify(exactly = 1) { swapClick.invoke() }
verify(exactly = 1) { transferClick.invoke() }
}
@Test
fun `GIVEN add-funds button WHEN transform THEN other fields of the button are preserved`() {
// GIVEN
val transformer = BindAddFundsActionButtonTransformer(onClick = onClick, onLongClick = onLongClick)
val state = stateWithButtons()
// WHEN
val result = transformer.transform(state)
// THEN
val original = state.balanceBlockUM.addFundsButton
val updated = result.balanceBlockUM.addFundsButton
assertThat(updated.text).isEqualTo(original.text)
assertThat(updated.tangemIconUM).isEqualTo(original.tangemIconUM)
assertThat(updated.type).isEqualTo(original.type)
assertThat(updated.isEnabled).isEqualTo(original.isEnabled)
}
@Test
fun `GIVEN buttons WHEN transform THEN unrelated state fields are preserved`() {
// GIVEN
val transformer = BindAddFundsActionButtonTransformer(onClick = onClick, onLongClick = onLongClick)
val state = stateWithButtons()
// WHEN
val result = transformer.transform(state)
// THEN
assertThat(result.topAppBarUM).isSameInstanceAs(state.topAppBarUM)
assertThat(result.addFundsUM).isSameInstanceAs(state.addFundsUM)
assertThat(result.transferUM).isSameInstanceAs(state.transferUM)
}
private fun stateWithButtons(): TokenDetailsUM = TokenDetailsUM(
topAppBarUM = TokenDetailsTopAppBarUM(
titleState = TitleState.Simple(tokenName = "Tether"),
subtitle = stringReference("USDT"),
onBackClick = {},
menuItems = persistentListOf(),
),
balanceBlockUM = TokenDetailsBalanceBlockUM.Loading(
addFundsButton = button(text = "Add funds", onClick = previousAddFundsClick),
swapButton = button(text = "Swap", onClick = swapClick),
transferButton = button(text = "Transfer", onClick = transferClick),
tokenBalanceTypeUM = TokenBalanceTypeUM.Single,
currencyIconState = CurrencyIconState.Loading,
),
notifications = persistentListOf(),
marketPriceBlockState = mockk<MarketPriceBlockState>(relaxed = true),
earnBlockState = null,
pullToRefreshConfig = mockk<PullToRefreshConfig>(relaxed = true),
isBalanceHidden = false,
isMarketPriceAvailable = false,
addFundsUM = AddFundsUM.Loading,
transferUM = TransferUM.Loading,
zeroBalanceActionsUM = ZeroBalanceActionsUM.Loading,
)
private fun button(text: String, onClick: () -> Unit) = TangemButtonUM(
text = stringReference(text),
type = TangemButtonType.Secondary,
onClick = onClick,
)
}

View file

@ -0,0 +1,123 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer
import com.google.common.truth.Truth.assertThat
import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.marketprice.MarketPriceBlockState
import com.tangem.core.ui.ds.button.TangemButtonType
import com.tangem.core.ui.ds.button.TangemButtonUM
import com.tangem.core.ui.extensions.stringReference
import com.tangem.feature.tokendetails.presentation.tokendetails.state.AddFundsUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceTypeUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM.TitleState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TransferUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.ZeroBalanceActionsUM
import io.mockk.mockk
import io.mockk.verify
import kotlinx.collections.immutable.persistentListOf
import org.junit.jupiter.api.Test
class BindTransferActionButtonTransformerTest {
private val onClick: () -> Unit = mockk(relaxed = true)
private val addFundsClick: () -> Unit = mockk(relaxed = true)
private val swapClick: () -> Unit = mockk(relaxed = true)
private val previousTransferClick: () -> Unit = mockk(relaxed = true)
@Test
fun `GIVEN buttons WHEN transform THEN onClick of transfer button is replaced`() {
// GIVEN
val transformer = BindTransferActionButtonTransformer(onClick = onClick)
// WHEN
val result = transformer.transform(stateWithButtons())
result.balanceBlockUM.transferButton.onClick()
// THEN
verify(exactly = 1) { onClick.invoke() }
verify(exactly = 0) { previousTransferClick.invoke() }
}
@Test
fun `GIVEN buttons WHEN transform THEN AddFunds and Swap onClick are untouched`() {
// GIVEN
val transformer = BindTransferActionButtonTransformer(onClick = onClick)
// WHEN
val result = transformer.transform(stateWithButtons())
result.balanceBlockUM.addFundsButton.onClick()
result.balanceBlockUM.swapButton.onClick()
// THEN
verify(exactly = 0) { onClick.invoke() }
verify(exactly = 1) { addFundsClick.invoke() }
verify(exactly = 1) { swapClick.invoke() }
}
@Test
fun `GIVEN transfer button WHEN transform THEN other fields of the button are preserved`() {
// GIVEN
val transformer = BindTransferActionButtonTransformer(onClick = onClick)
val state = stateWithButtons()
// WHEN
val result = transformer.transform(state)
// THEN
val original = state.balanceBlockUM.transferButton
val updated = result.balanceBlockUM.transferButton
assertThat(updated.text).isEqualTo(original.text)
assertThat(updated.tangemIconUM).isEqualTo(original.tangemIconUM)
assertThat(updated.type).isEqualTo(original.type)
assertThat(updated.isEnabled).isEqualTo(original.isEnabled)
}
@Test
fun `GIVEN buttons WHEN transform THEN unrelated state fields are preserved`() {
// GIVEN
val transformer = BindTransferActionButtonTransformer(onClick = onClick)
val state = stateWithButtons()
// WHEN
val result = transformer.transform(state)
// THEN
assertThat(result.topAppBarUM).isSameInstanceAs(state.topAppBarUM)
assertThat(result.addFundsUM).isSameInstanceAs(state.addFundsUM)
assertThat(result.transferUM).isSameInstanceAs(state.transferUM)
}
private fun stateWithButtons(): TokenDetailsUM = TokenDetailsUM(
topAppBarUM = TokenDetailsTopAppBarUM(
titleState = TitleState.Simple(tokenName = "Tether"),
subtitle = stringReference("USDT"),
onBackClick = {},
menuItems = persistentListOf(),
),
balanceBlockUM = TokenDetailsBalanceBlockUM.Loading(
addFundsButton = button(text = "Add funds", onClick = addFundsClick),
swapButton = button(text = "Swap", onClick = swapClick),
transferButton = button(text = "Transfer", onClick = previousTransferClick),
tokenBalanceTypeUM = TokenBalanceTypeUM.Single,
currencyIconState = CurrencyIconState.Loading,
),
notifications = persistentListOf(),
marketPriceBlockState = mockk<MarketPriceBlockState>(relaxed = true),
earnBlockState = null,
pullToRefreshConfig = mockk<PullToRefreshConfig>(relaxed = true),
isBalanceHidden = false,
isMarketPriceAvailable = false,
addFundsUM = AddFundsUM.Loading,
transferUM = TransferUM.Loading,
zeroBalanceActionsUM = ZeroBalanceActionsUM.Loading,
)
private fun button(text: String, onClick: () -> Unit) = TangemButtonUM(
text = stringReference(text),
type = TangemButtonType.Secondary,
onClick = onClick,
)
}

View file

@ -3,13 +3,18 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.state.transfor
import com.google.common.truth.Truth.assertThat
import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig
import com.tangem.core.ui.components.marketprice.MarketPriceBlockState
import com.tangem.core.ui.ds.button.TangemButtonType
import com.tangem.core.ui.ds.button.TangemButtonUM
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.feature.tokendetails.presentation.tokendetails.state.AddFundsUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceTypeUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM.TitleState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TransferUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.ZeroBalanceActionsUM
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
@ -106,7 +111,9 @@ class InitializeWithCryptoCurrencyTransformerTest {
// THEN — only top bar title/subtitle/onBackClick, marketPriceBlockState and pullToRefresh.onRefresh are touched
assertThat(result.topAppBarUM.menuItems).isEqualTo(state.topAppBarUM.menuItems)
assertThat(result.balanceBlockUM.actionButtons).isEqualTo(state.balanceBlockUM.actionButtons)
assertThat(result.balanceBlockUM.addFundsButton).isEqualTo(state.balanceBlockUM.addFundsButton)
assertThat(result.balanceBlockUM.swapButton).isEqualTo(state.balanceBlockUM.swapButton)
assertThat(result.balanceBlockUM.transferButton).isEqualTo(state.balanceBlockUM.transferButton)
assertThat(result.balanceBlockUM.tokenBalanceTypeUM).isEqualTo(state.balanceBlockUM.tokenBalanceTypeUM)
assertThat(result.earnBlockState).isEqualTo(state.earnBlockState)
assertThat(result.pullToRefreshConfig.isRefreshing).isEqualTo(state.pullToRefreshConfig.isRefreshing)
@ -139,7 +146,9 @@ class InitializeWithCryptoCurrencyTransformerTest {
menuItems = persistentListOf(),
),
balanceBlockUM = TokenDetailsBalanceBlockUM.Loading(
actionButtons = persistentListOf(),
addFundsButton = placeholderButton(),
swapButton = placeholderButton(),
transferButton = placeholderButton(),
tokenBalanceTypeUM = TokenBalanceTypeUM.Single,
currencyIconState = mockk(relaxed = true),
),
@ -149,6 +158,15 @@ class InitializeWithCryptoCurrencyTransformerTest {
pullToRefreshConfig = PullToRefreshConfig(isRefreshing = false, onRefresh = {}),
isBalanceHidden = false,
isMarketPriceAvailable = false,
addFundsUM = AddFundsUM.Loading,
transferUM = TransferUM.Loading,
zeroBalanceActionsUM = ZeroBalanceActionsUM.Loading,
)
private fun placeholderButton(): TangemButtonUM = TangemButtonUM(
text = stringReference(""),
type = TangemButtonType.Secondary,
onClick = {},
)
private companion object {

View file

@ -7,13 +7,15 @@ import com.tangem.core.ui.components.marketprice.MarketPriceBlockState
import com.tangem.core.ui.ds.button.TangemButtonType
import com.tangem.core.ui.ds.button.TangemButtonUM
import com.tangem.core.ui.extensions.stringReference
import com.tangem.feature.tokendetails.presentation.tokendetails.state.AddFundsUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceTypeUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM.TitleState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TransferUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.ZeroBalanceActionsUM
import io.mockk.mockk
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import org.junit.jupiter.api.Test
@ -48,22 +50,23 @@ class SetBalanceLoadingTransformerTest {
@Test
fun `GIVEN state with action buttons WHEN transform THEN action buttons are preserved`() {
// GIVEN
val buttons = persistentListOf(
TangemButtonUM(
text = stringReference("Test"),
onClick = {},
isEnabled = true,
type = TangemButtonType.Secondary,
),
val addFunds = button(text = "Add funds")
val swap = button(text = "Swap")
val transfer = button(text = "Transfer")
val state = initialState(
addFundsButton = addFunds,
swapButton = swap,
transferButton = transfer,
)
val state = initialState(actionButtons = buttons)
val transformer = SetBalanceLoadingTransformer(currencyIconState = currencyIconState)
// WHEN
val result = transformer.transform(state)
// THEN
assertThat(result.balanceBlockUM.actionButtons).isEqualTo(buttons)
assertThat(result.balanceBlockUM.addFundsButton).isSameInstanceAs(addFunds)
assertThat(result.balanceBlockUM.swapButton).isSameInstanceAs(swap)
assertThat(result.balanceBlockUM.transferButton).isSameInstanceAs(transfer)
}
@Test
@ -83,7 +86,9 @@ class SetBalanceLoadingTransformerTest {
// GIVEN
val contentState = initialState().copy(
balanceBlockUM = TokenDetailsBalanceBlockUM.Content(
actionButtons = persistentListOf(),
addFundsButton = button(text = "Add funds"),
swapButton = button(text = "Swap"),
transferButton = button(text = "Transfer"),
tokenBalanceTypeUM = TokenBalanceTypeUM.Single,
currencyIconState = CurrencyIconState.Loading,
displayCryptoBalanceAll = stringReference("1.0 BTC"),
@ -91,6 +96,7 @@ class SetBalanceLoadingTransformerTest {
displayCryptoBalanceAvailable = null,
displayFiatBalanceAvailable = null,
isBalanceFlickering = false,
isBalanceZero = false,
),
)
val transformer = SetBalanceLoadingTransformer(currencyIconState = currencyIconState)
@ -121,7 +127,9 @@ class SetBalanceLoadingTransformerTest {
}
private fun initialState(
actionButtons: ImmutableList<TangemButtonUM> = persistentListOf(),
addFundsButton: TangemButtonUM = button(text = "Add funds"),
swapButton: TangemButtonUM = button(text = "Swap"),
transferButton: TangemButtonUM = button(text = "Transfer"),
): TokenDetailsUM = TokenDetailsUM(
topAppBarUM = TokenDetailsTopAppBarUM(
titleState = TitleState.Simple(tokenName = ""),
@ -130,7 +138,9 @@ class SetBalanceLoadingTransformerTest {
menuItems = persistentListOf(),
),
balanceBlockUM = TokenDetailsBalanceBlockUM.Loading(
actionButtons = actionButtons,
addFundsButton = addFundsButton,
swapButton = swapButton,
transferButton = transferButton,
tokenBalanceTypeUM = TokenBalanceTypeUM.Single,
currencyIconState = CurrencyIconState.Loading,
),
@ -140,5 +150,14 @@ class SetBalanceLoadingTransformerTest {
pullToRefreshConfig = mockk<PullToRefreshConfig>(relaxed = true),
isBalanceHidden = false,
isMarketPriceAvailable = false,
addFundsUM = AddFundsUM.Loading,
transferUM = TransferUM.Loading,
zeroBalanceActionsUM = ZeroBalanceActionsUM.Loading,
)
private fun button(text: String): TangemButtonUM = TangemButtonUM(
text = stringReference(text),
type = TangemButtonType.Secondary,
onClick = {},
)
}

View file

@ -5,6 +5,8 @@ import com.tangem.common.getTotalWithRewardsStakingBalance
import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.marketprice.MarketPriceBlockState
import com.tangem.core.ui.ds.button.TangemButtonType
import com.tangem.core.ui.ds.button.TangemButtonUM
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.StatusSource
@ -12,11 +14,14 @@ import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.staking.StakingBalance
import com.tangem.feature.tokendetails.presentation.tokendetails.state.AddFundsUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceTypeUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM.TitleState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TransferUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.ZeroBalanceActionsUM
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
@ -168,12 +173,15 @@ class SetBalanceTransformerTest {
// GIVEN
val status = createStatus(loadedValue())
val transformer = createTransformer(status)
val state = initialState()
// WHEN
val result = transformer.transform(initialState())
val result = transformer.transform(state)
// THEN
assertThat(result.balanceBlockUM.actionButtons).isEqualTo(initialState().balanceBlockUM.actionButtons)
assertThat(result.balanceBlockUM.addFundsButton).isEqualTo(state.balanceBlockUM.addFundsButton)
assertThat(result.balanceBlockUM.swapButton).isEqualTo(state.balanceBlockUM.swapButton)
assertThat(result.balanceBlockUM.transferButton).isEqualTo(state.balanceBlockUM.transferButton)
}
@Test
@ -271,7 +279,9 @@ class SetBalanceTransformerTest {
val transformer = createTransformer(status)
val prevContent = TokenDetailsBalanceBlockUM.Content(
actionButtons = persistentListOf(),
addFundsButton = placeholderButton(),
swapButton = placeholderButton(),
transferButton = placeholderButton(),
tokenBalanceTypeUM = TokenBalanceTypeUM.Multiple(
type = TokenBalanceTypeUM.Type.AVAILABLE,
availableTypes = persistentListOf(TokenBalanceTypeUM.Type.ALL, TokenBalanceTypeUM.Type.AVAILABLE),
@ -283,6 +293,7 @@ class SetBalanceTransformerTest {
displayCryptoBalanceAvailable = null,
displayFiatBalanceAvailable = null,
isBalanceFlickering = false,
isBalanceZero = false,
)
val state = initialState().copy(balanceBlockUM = prevContent)
@ -339,6 +350,54 @@ class SetBalanceTransformerTest {
// endregion
// region isBalanceZero
@Test
fun `GIVEN amount is zero WHEN transform THEN isBalanceZero is true`() {
// GIVEN
val status = createStatus(loadedValue(amount = BigDecimal.ZERO, stakingBalance = null))
val transformer = createTransformer(status)
// WHEN
val result = transformer.transform(initialState())
// THEN
val content = result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content
assertThat(content.isBalanceZero).isTrue()
}
@Test
fun `GIVEN non-zero amount WHEN transform THEN isBalanceZero is false`() {
// GIVEN
val status = createStatus(loadedValue(amount = BigDecimal("0.001"), stakingBalance = null))
val transformer = createTransformer(status)
// WHEN
val result = transformer.transform(initialState())
// THEN
val content = result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content
assertThat(content.isBalanceZero).isFalse()
}
@Test
fun `GIVEN zero amount but non-zero staking WHEN transform THEN isBalanceZero is false`() {
// GIVEN — staking balance counts towards "total" so amount+staking != 0 keeps the rich UI
val stakingBalance: StakingBalance.Data = mockk(relaxed = true)
every { stakingBalance.getTotalWithRewardsStakingBalance(any()) } returns BigDecimal("1.5")
val status = createStatus(loadedValue(amount = BigDecimal.ZERO, stakingBalance = stakingBalance))
val transformer = createTransformer(status)
// WHEN
val result = transformer.transform(initialState())
// THEN
val content = result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content
assertThat(content.isBalanceZero).isFalse()
}
// endregion
// region No staking → available balances
@Test
@ -468,7 +527,9 @@ class SetBalanceTransformerTest {
menuItems = persistentListOf(),
),
balanceBlockUM = TokenDetailsBalanceBlockUM.Loading(
actionButtons = persistentListOf(),
addFundsButton = placeholderButton(),
swapButton = placeholderButton(),
transferButton = placeholderButton(),
tokenBalanceTypeUM = TokenBalanceTypeUM.Single,
currencyIconState = CurrencyIconState.Loading,
),
@ -478,5 +539,14 @@ class SetBalanceTransformerTest {
pullToRefreshConfig = mockk<PullToRefreshConfig>(relaxed = true),
isBalanceHidden = false,
isMarketPriceAvailable = false,
addFundsUM = AddFundsUM.Loading,
transferUM = TransferUM.Loading,
zeroBalanceActionsUM = ZeroBalanceActionsUM.Loading,
)
private fun placeholderButton(): TangemButtonUM = TangemButtonUM(
text = stringReference(""),
type = TangemButtonType.Secondary,
onClick = {},
)
}

View file

@ -11,10 +11,13 @@ import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountName
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.feature.tokendetails.presentation.tokendetails.state.AddFundsUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM.TitleState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TransferUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.ZeroBalanceActionsUM
import io.mockk.every
import io.mockk.mockk
import kotlinx.collections.immutable.persistentListOf
@ -201,5 +204,8 @@ class SetTopBarTitleTransformerTest {
pullToRefreshConfig = mockk<PullToRefreshConfig>(relaxed = true),
isBalanceHidden = false,
isMarketPriceAvailable = false,
addFundsUM = AddFundsUM.Loading,
transferUM = TransferUM.Loading,
zeroBalanceActionsUM = ZeroBalanceActionsUM.Loading,
)
}

View file

@ -4,12 +4,17 @@ import com.google.common.truth.Truth.assertThat
import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.marketprice.MarketPriceBlockState
import com.tangem.core.ui.ds.button.TangemButtonType
import com.tangem.core.ui.ds.button.TangemButtonUM
import com.tangem.core.ui.extensions.stringReference
import com.tangem.feature.tokendetails.presentation.tokendetails.state.AddFundsUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceTypeUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM.TitleState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TransferUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.ZeroBalanceActionsUM
import io.mockk.mockk
import kotlinx.collections.immutable.persistentListOf
import org.junit.jupiter.api.Test
@ -83,7 +88,9 @@ class ToggleBalanceTypeTransformerTest {
// GIVEN
val state = initialState().copy(
balanceBlockUM = TokenDetailsBalanceBlockUM.Error(
actionButtons = persistentListOf(),
addFundsButton = placeholderButton(),
swapButton = placeholderButton(),
transferButton = placeholderButton(),
tokenBalanceTypeUM = TokenBalanceTypeUM.Single,
currencyIconState = CurrencyIconState.Loading,
),
@ -101,7 +108,9 @@ class ToggleBalanceTypeTransformerTest {
// GIVEN
val state = initialState().copy(
balanceBlockUM = TokenDetailsBalanceBlockUM.Content(
actionButtons = persistentListOf(),
addFundsButton = placeholderButton(),
swapButton = placeholderButton(),
transferButton = placeholderButton(),
tokenBalanceTypeUM = TokenBalanceTypeUM.Single,
currencyIconState = CurrencyIconState.Loading,
displayCryptoBalanceAll = stringReference("1.0 ETH"),
@ -109,6 +118,7 @@ class ToggleBalanceTypeTransformerTest {
displayCryptoBalanceAvailable = null,
displayFiatBalanceAvailable = null,
isBalanceFlickering = false,
isBalanceZero = false,
),
)
@ -151,7 +161,9 @@ class ToggleBalanceTypeTransformerTest {
// THEN
val resultContent = result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content
assertThat(resultContent.actionButtons).isEqualTo(originalContent.actionButtons)
assertThat(resultContent.addFundsButton).isEqualTo(originalContent.addFundsButton)
assertThat(resultContent.swapButton).isEqualTo(originalContent.swapButton)
assertThat(resultContent.transferButton).isEqualTo(originalContent.transferButton)
assertThat(resultContent.currencyIconState).isEqualTo(originalContent.currencyIconState)
assertThat(resultContent.displayCryptoBalanceAll).isEqualTo(originalContent.displayCryptoBalanceAll)
assertThat(resultContent.displayFiatBalanceAll).isEqualTo(originalContent.displayFiatBalanceAll)
@ -163,7 +175,9 @@ class ToggleBalanceTypeTransformerTest {
private fun stateWithContent(type: TokenBalanceTypeUM.Type): TokenDetailsUM {
return initialState().copy(
balanceBlockUM = TokenDetailsBalanceBlockUM.Content(
actionButtons = persistentListOf(),
addFundsButton = placeholderButton(),
swapButton = placeholderButton(),
transferButton = placeholderButton(),
tokenBalanceTypeUM = TokenBalanceTypeUM.Multiple(
type = type,
availableTypes = persistentListOf(
@ -178,6 +192,7 @@ class ToggleBalanceTypeTransformerTest {
displayCryptoBalanceAvailable = stringReference("9.0 ETH"),
displayFiatBalanceAvailable = stringReference("$18,000"),
isBalanceFlickering = false,
isBalanceZero = false,
),
)
}
@ -190,7 +205,9 @@ class ToggleBalanceTypeTransformerTest {
menuItems = persistentListOf(),
),
balanceBlockUM = TokenDetailsBalanceBlockUM.Loading(
actionButtons = persistentListOf(),
addFundsButton = placeholderButton(),
swapButton = placeholderButton(),
transferButton = placeholderButton(),
tokenBalanceTypeUM = TokenBalanceTypeUM.Single,
currencyIconState = CurrencyIconState.Loading,
),
@ -200,5 +217,14 @@ class ToggleBalanceTypeTransformerTest {
pullToRefreshConfig = mockk<PullToRefreshConfig>(relaxed = true),
isBalanceHidden = false,
isMarketPriceAvailable = false,
addFundsUM = AddFundsUM.Loading,
transferUM = TransferUM.Loading,
zeroBalanceActionsUM = ZeroBalanceActionsUM.Loading,
)
private fun placeholderButton(): TangemButtonUM = TangemButtonUM(
text = stringReference(""),
type = TangemButtonType.Secondary,
onClick = {},
)
}

View file

@ -0,0 +1,288 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer
import com.google.common.truth.Truth.assertThat
import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig
import com.tangem.core.ui.components.marketprice.MarketPriceBlockState
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason
import com.tangem.domain.tokens.model.TokenActionsState
import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents
import com.tangem.feature.tokendetails.presentation.tokendetails.state.AddFundsUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM.TitleState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TransferUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.ZeroBalanceActionsUM
import io.mockk.mockk
import io.mockk.verify
import io.mockk.verifyOrder
import kotlinx.collections.immutable.persistentListOf
import org.junit.jupiter.api.Test
class UpdateAddFundsTransformerTest {
private val clickIntents: TokenDetailsClickIntents = mockk(relaxed = true)
private val onActionDispatched: () -> Unit = mockk(relaxed = true)
@Test
fun `GIVEN actions without Buy, Swap nor Receive WHEN transform THEN state is unchanged`() {
// GIVEN
val transformer = createTransformer(
actions = listOf(TokenActionsState.ActionState.Send(ScenarioUnavailabilityReason.None)),
)
val state = initialState()
// WHEN
val result = transformer.transform(state)
// THEN
assertThat(result).isSameInstanceAs(state)
assertThat(result.addFundsUM).isInstanceOf(AddFundsUM.Loading::class.java)
}
@Test
fun `GIVEN both Buy and Receive available WHEN transform THEN Content carries both rows`() {
// GIVEN
val transformer = createTransformer(
actions = listOf(
TokenActionsState.ActionState.Buy(ScenarioUnavailabilityReason.None),
TokenActionsState.ActionState.Receive(ScenarioUnavailabilityReason.None),
),
)
// WHEN
val result = transformer.transform(initialState())
// THEN
val content = result.addFundsUM as AddFundsUM.Content
assertThat(content.buy).isNotNull()
assertThat(content.receive).isNotNull()
}
@Test
fun `GIVEN Buy disabled AND Receive available WHEN transform THEN Buy row stays visible but disabled`() {
// GIVEN
val transformer = createTransformer(
actions = listOf(
TokenActionsState.ActionState.Buy(ScenarioUnavailabilityReason.BuyUnavailable("USDT")),
TokenActionsState.ActionState.Receive(ScenarioUnavailabilityReason.None),
),
)
// WHEN
val result = transformer.transform(initialState())
// THEN
val content = result.addFundsUM as AddFundsUM.Content
assertThat(content.buy).isNotNull()
assertThat(content.buy?.isEnabled).isFalse()
assertThat(content.receive?.isEnabled).isTrue()
}
@Test
fun `GIVEN only Buy available WHEN transform THEN Receive row is null`() {
// GIVEN
val transformer = createTransformer(
actions = listOf(TokenActionsState.ActionState.Buy(ScenarioUnavailabilityReason.None)),
)
// WHEN
val result = transformer.transform(initialState())
// THEN
val content = result.addFundsUM as AddFundsUM.Content
assertThat(content.buy).isNotNull()
assertThat(content.receive).isNull()
}
@Test
fun `GIVEN Buy row WHEN onClick invoked THEN dispatcher fires before buy click`() {
// GIVEN
val transformer = createTransformer(
actions = listOf(TokenActionsState.ActionState.Buy(ScenarioUnavailabilityReason.None)),
)
// WHEN
val content = transformer.transform(initialState()).addFundsUM as AddFundsUM.Content
content.buy!!.onClick()
// THEN
verifyOrder {
onActionDispatched.invoke()
clickIntents.onBuyClick(ScenarioUnavailabilityReason.None)
}
}
@Test
fun `GIVEN Receive row WHEN long-clicked THEN dispatcher fires before copy address`() {
// GIVEN
val transformer = createTransformer(
actions = listOf(TokenActionsState.ActionState.Receive(ScenarioUnavailabilityReason.None)),
)
// WHEN
val content = transformer.transform(initialState()).addFundsUM as AddFundsUM.Content
content.receive!!.onLongClick!!()
// THEN
verifyOrder {
onActionDispatched.invoke()
clickIntents.onCopyAddress()
}
}
@Test
fun `GIVEN Receive row WHEN onClick invoked THEN dispatcher fires before receive click`() {
// GIVEN
val transformer = createTransformer(
actions = listOf(TokenActionsState.ActionState.Receive(ScenarioUnavailabilityReason.None)),
)
// WHEN
val content = transformer.transform(initialState()).addFundsUM as AddFundsUM.Content
content.receive!!.onClick()
// THEN
verifyOrder {
onActionDispatched.invoke()
clickIntents.onReceiveClick(ScenarioUnavailabilityReason.None)
}
}
@Test
fun `GIVEN actions WHEN transform THEN unrelated state fields are preserved`() {
// GIVEN
val state = initialState()
val transformer = createTransformer(
actions = listOf(TokenActionsState.ActionState.Buy(ScenarioUnavailabilityReason.None)),
)
// WHEN
val result = transformer.transform(state)
// THEN
assertThat(result.transferUM).isSameInstanceAs(state.transferUM)
assertThat(result.balanceBlockUM).isSameInstanceAs(state.balanceBlockUM)
assertThat(result.topAppBarUM).isSameInstanceAs(state.topAppBarUM)
}
@Test
fun `GIVEN both Buy and Receive disabled WHEN transform THEN Content shows both rows disabled`() {
// GIVEN
val transformer = createTransformer(
actions = listOf(
TokenActionsState.ActionState.Buy(ScenarioUnavailabilityReason.BuyUnavailable("USDT")),
TokenActionsState.ActionState.Receive(ScenarioUnavailabilityReason.UnassociatedAsset),
),
)
// WHEN
val result = transformer.transform(initialState())
// THEN
val content = result.addFundsUM as AddFundsUM.Content
assertThat(content.buy?.isEnabled).isFalse()
assertThat(content.receive?.isEnabled).isFalse()
}
@Test
fun `GIVEN disabled Buy WHEN onClick invoked THEN buy click receives the unavailability reason`() {
// GIVEN — Row.onClick is always wired; UI gating decides whether it fires. This test
// guards the wiring: when the row IS invoked, the reason is forwarded.
val reason = ScenarioUnavailabilityReason.BuyUnavailable("USDT")
val transformer = createTransformer(
actions = listOf(TokenActionsState.ActionState.Buy(reason)),
)
// WHEN
val content = transformer.transform(initialState()).addFundsUM as AddFundsUM.Content
content.buy!!.onClick()
// THEN
verifyOrder {
onActionDispatched.invoke()
clickIntents.onBuyClick(reason)
}
}
@Test
fun `GIVEN action with loading marker reason WHEN transform THEN that row is marked isLoading`() {
// GIVEN — DataLoading/ExpressLoading signal the underlying data is still being fetched.
// The row stays in Content but with isLoading=true so the UI keeps the spinner.
val transformer = createTransformer(
actions = listOf(
TokenActionsState.ActionState.Buy(ScenarioUnavailabilityReason.ExpressLoading("USDT")),
TokenActionsState.ActionState.Receive(ScenarioUnavailabilityReason.None),
),
)
// WHEN
val result = transformer.transform(initialState())
// THEN
val content = result.addFundsUM as AddFundsUM.Content
assertThat(content.buy?.isLoading).isTrue()
assertThat(content.buy?.isEnabled).isFalse()
assertThat(content.receive?.isLoading).isFalse()
assertThat(content.receive?.isEnabled).isTrue()
}
@Test
fun `GIVEN Swap row WHEN onClick invoked THEN swap-to click receives the reason`() {
// GIVEN — AddFunds context implies "swap something INTO this token", direction = TO.
val transformer = createTransformer(
actions = listOf(TokenActionsState.ActionState.Swap(ScenarioUnavailabilityReason.None, false)),
)
// WHEN
val content = transformer.transform(initialState()).addFundsUM as AddFundsUM.Content
content.swap!!.onClick()
// THEN
verifyOrder {
onActionDispatched.invoke()
clickIntents.onSwapToClick(ScenarioUnavailabilityReason.None)
}
}
@Test
fun `GIVEN row WHEN not clicked THEN no callbacks fire`() {
// GIVEN
val transformer = createTransformer(
actions = listOf(TokenActionsState.ActionState.Buy(ScenarioUnavailabilityReason.None)),
)
// WHEN
transformer.transform(initialState())
// THEN
verify(exactly = 0) { onActionDispatched.invoke() }
verify(exactly = 0) { clickIntents.onBuyClick(any()) }
}
private fun createTransformer(actions: List<TokenActionsState.ActionState>) = UpdateAddFundsTransformer(
actions = actions,
clickIntents = clickIntents,
onActionDispatched = onActionDispatched,
)
private fun initialState(): TokenDetailsUM = TokenDetailsUM(
topAppBarUM = TokenDetailsTopAppBarUM(
titleState = TitleState.Simple(tokenName = "Tether"),
subtitle = stringReference("USDT"),
onBackClick = {},
menuItems = persistentListOf(),
),
balanceBlockUM = mockk<TokenDetailsBalanceBlockUM>(relaxed = true),
notifications = persistentListOf(),
marketPriceBlockState = mockk<MarketPriceBlockState>(relaxed = true),
earnBlockState = null,
pullToRefreshConfig = mockk<PullToRefreshConfig>(relaxed = true),
isBalanceHidden = false,
isMarketPriceAvailable = false,
addFundsUM = AddFundsUM.Loading,
transferUM = TransferUM.Loading,
zeroBalanceActionsUM = ZeroBalanceActionsUM.Loading,
)
}

View file

@ -11,10 +11,13 @@ import com.tangem.domain.tokens.model.warnings.DynamicAddressesWarnings
import com.tangem.domain.tokens.model.warnings.HederaWarnings
import com.tangem.domain.tokens.model.warnings.KaspaWarnings
import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents
import com.tangem.feature.tokendetails.presentation.tokendetails.state.AddFundsUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM.TitleState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TransferUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.ZeroBalanceActionsUM
import io.mockk.mockk
import io.mockk.verify
import kotlinx.collections.immutable.persistentListOf
@ -568,5 +571,8 @@ class UpdateNotificationsTransformerTest {
pullToRefreshConfig = mockk<PullToRefreshConfig>(relaxed = true),
isBalanceHidden = false,
isMarketPriceAvailable = false,
addFundsUM = AddFundsUM.Loading,
transferUM = TransferUM.Loading,
zeroBalanceActionsUM = ZeroBalanceActionsUM.Loading,
)
}

View file

@ -14,10 +14,13 @@ import com.tangem.domain.staking.model.StakingAvailability
import com.tangem.domain.staking.model.StakingEntryInfo
import com.tangem.domain.staking.model.StakingOption
import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents
import com.tangem.feature.tokendetails.presentation.tokendetails.state.AddFundsUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM.TitleState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TransferUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.ZeroBalanceActionsUM
import io.mockk.every
import io.mockk.mockk
import kotlinx.collections.immutable.persistentListOf
@ -133,5 +136,8 @@ class UpdateStakingNotificationTransformerTest {
pullToRefreshConfig = mockk<PullToRefreshConfig>(relaxed = true),
isBalanceHidden = false,
isMarketPriceAvailable = false,
addFundsUM = AddFundsUM.Loading,
transferUM = TransferUM.Loading,
zeroBalanceActionsUM = ZeroBalanceActionsUM.Loading,
)
}

View file

@ -7,10 +7,13 @@ import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.card.CardTypesResolver
import com.tangem.domain.card.common.util.cardTypesResolver
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.feature.tokendetails.presentation.tokendetails.state.AddFundsUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM.TitleState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TransferUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.ZeroBalanceActionsUM
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
@ -211,5 +214,8 @@ class UpdateTopBarMenuTransformerTest {
pullToRefreshConfig = mockk<PullToRefreshConfig>(relaxed = true),
isBalanceHidden = false,
isMarketPriceAvailable = false,
addFundsUM = AddFundsUM.Loading,
transferUM = TransferUM.Loading,
zeroBalanceActionsUM = ZeroBalanceActionsUM.Loading,
)
}

View file

@ -0,0 +1,271 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer
import com.google.common.truth.Truth.assertThat
import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig
import com.tangem.core.ui.components.marketprice.MarketPriceBlockState
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason
import com.tangem.domain.tokens.model.TokenActionsState
import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents
import com.tangem.feature.tokendetails.presentation.tokendetails.state.AddFundsUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM.TitleState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TransferUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.ZeroBalanceActionsUM
import io.mockk.mockk
import io.mockk.verify
import io.mockk.verifyOrder
import kotlinx.collections.immutable.persistentListOf
import org.junit.jupiter.api.Test
class UpdateTransferTransformerTest {
private val clickIntents: TokenDetailsClickIntents = mockk(relaxed = true)
private val onActionDispatched: () -> Unit = mockk(relaxed = true)
@Test
fun `GIVEN actions without Send nor Sell WHEN transform THEN state is unchanged`() {
// GIVEN
val transformer = createTransformer(
actions = listOf(TokenActionsState.ActionState.Buy(ScenarioUnavailabilityReason.None)),
)
val state = initialState()
// WHEN
val result = transformer.transform(state)
// THEN
assertThat(result).isSameInstanceAs(state)
assertThat(result.transferUM).isInstanceOf(TransferUM.Loading::class.java)
}
@Test
fun `GIVEN both Send and Sell available WHEN transform THEN Content carries both rows`() {
// GIVEN
val transformer = createTransformer(
actions = listOf(
TokenActionsState.ActionState.Send(ScenarioUnavailabilityReason.None),
TokenActionsState.ActionState.Sell(ScenarioUnavailabilityReason.None),
),
)
// WHEN
val result = transformer.transform(initialState())
// THEN
val content = result.transferUM as TransferUM.Content
assertThat(content.send).isNotNull()
assertThat(content.sell).isNotNull()
}
@Test
fun `GIVEN Send disabled AND Sell available WHEN transform THEN Send row stays visible but disabled`() {
// GIVEN
val transformer = createTransformer(
actions = listOf(
TokenActionsState.ActionState.Send(ScenarioUnavailabilityReason.UsedOutdatedData),
TokenActionsState.ActionState.Sell(ScenarioUnavailabilityReason.None),
),
)
// WHEN
val result = transformer.transform(initialState())
// THEN
val content = result.transferUM as TransferUM.Content
assertThat(content.send?.isEnabled).isFalse()
assertThat(content.sell?.isEnabled).isTrue()
}
@Test
fun `GIVEN Sell disabled AND Send available WHEN transform THEN Sell row stays visible but disabled`() {
// GIVEN
val transformer = createTransformer(
actions = listOf(
TokenActionsState.ActionState.Send(ScenarioUnavailabilityReason.None),
TokenActionsState.ActionState.Sell(ScenarioUnavailabilityReason.NotSupportedBySellService("USDT")),
),
)
// WHEN
val result = transformer.transform(initialState())
// THEN
val content = result.transferUM as TransferUM.Content
assertThat(content.send?.isEnabled).isTrue()
assertThat(content.sell?.isEnabled).isFalse()
}
@Test
fun `GIVEN Send row WHEN onClick invoked THEN dispatcher fires before send click`() {
// GIVEN
val transformer = createTransformer(
actions = listOf(TokenActionsState.ActionState.Send(ScenarioUnavailabilityReason.None)),
)
// WHEN
val content = transformer.transform(initialState()).transferUM as TransferUM.Content
content.send!!.onClick()
// THEN
verifyOrder {
onActionDispatched.invoke()
clickIntents.onSendClick(ScenarioUnavailabilityReason.None)
}
}
@Test
fun `GIVEN Sell row WHEN onClick invoked THEN dispatcher fires before sell click`() {
// GIVEN
val transformer = createTransformer(
actions = listOf(TokenActionsState.ActionState.Sell(ScenarioUnavailabilityReason.None)),
)
// WHEN
val content = transformer.transform(initialState()).transferUM as TransferUM.Content
content.sell!!.onClick()
// THEN
verifyOrder {
onActionDispatched.invoke()
clickIntents.onSellClick(ScenarioUnavailabilityReason.None)
}
}
@Test
fun `GIVEN actions WHEN transform THEN unrelated state fields are preserved`() {
// GIVEN
val state = initialState()
val transformer = createTransformer(
actions = listOf(TokenActionsState.ActionState.Send(ScenarioUnavailabilityReason.None)),
)
// WHEN
val result = transformer.transform(state)
// THEN
assertThat(result.addFundsUM).isSameInstanceAs(state.addFundsUM)
assertThat(result.balanceBlockUM).isSameInstanceAs(state.balanceBlockUM)
assertThat(result.topAppBarUM).isSameInstanceAs(state.topAppBarUM)
}
@Test
fun `GIVEN both Send and Sell disabled WHEN transform THEN Content shows both rows disabled`() {
// GIVEN
val transformer = createTransformer(
actions = listOf(
TokenActionsState.ActionState.Send(ScenarioUnavailabilityReason.UsedOutdatedData),
TokenActionsState.ActionState.Sell(ScenarioUnavailabilityReason.Unreachable),
),
)
// WHEN
val result = transformer.transform(initialState())
// THEN
val content = result.transferUM as TransferUM.Content
assertThat(content.send?.isEnabled).isFalse()
assertThat(content.sell?.isEnabled).isFalse()
}
@Test
fun `GIVEN disabled Send WHEN onClick invoked THEN send click receives the unavailability reason`() {
// GIVEN
val reason = ScenarioUnavailabilityReason.UsedOutdatedData
val transformer = createTransformer(
actions = listOf(TokenActionsState.ActionState.Send(reason)),
)
// WHEN
val content = transformer.transform(initialState()).transferUM as TransferUM.Content
content.send!!.onClick()
// THEN
verifyOrder {
onActionDispatched.invoke()
clickIntents.onSendClick(reason)
}
}
@Test
fun `GIVEN action with loading marker reason WHEN transform THEN that row is marked isLoading`() {
// GIVEN — see UpdateAddFundsTransformerTest for rationale. Send/Sell don't normally
// receive these markers in production, but the row UM honours them uniformly anyway.
val transformer = createTransformer(
actions = listOf(
TokenActionsState.ActionState.Send(ScenarioUnavailabilityReason.DataLoading),
TokenActionsState.ActionState.Sell(ScenarioUnavailabilityReason.None),
),
)
// WHEN
val result = transformer.transform(initialState())
// THEN
val content = result.transferUM as TransferUM.Content
assertThat(content.send?.isLoading).isTrue()
assertThat(content.send?.isEnabled).isFalse()
assertThat(content.sell?.isLoading).isFalse()
assertThat(content.sell?.isEnabled).isTrue()
}
@Test
fun `GIVEN Swap row WHEN onClick invoked THEN swap-from click receives the reason`() {
// GIVEN — Transfer context implies "swap THIS token to another", direction = FROM.
val transformer = createTransformer(
actions = listOf(TokenActionsState.ActionState.Swap(ScenarioUnavailabilityReason.None, false)),
)
// WHEN
val content = transformer.transform(initialState()).transferUM as TransferUM.Content
content.swap!!.onClick()
// THEN
verifyOrder {
onActionDispatched.invoke()
clickIntents.onSwapFromClick(ScenarioUnavailabilityReason.None)
}
}
@Test
fun `GIVEN row WHEN not clicked THEN no callbacks fire`() {
// GIVEN
val transformer = createTransformer(
actions = listOf(TokenActionsState.ActionState.Send(ScenarioUnavailabilityReason.None)),
)
// WHEN
transformer.transform(initialState())
// THEN
verify(exactly = 0) { onActionDispatched.invoke() }
verify(exactly = 0) { clickIntents.onSendClick(any()) }
}
private fun createTransformer(actions: List<TokenActionsState.ActionState>) = UpdateTransferTransformer(
actions = actions,
clickIntents = clickIntents,
onActionDispatched = onActionDispatched,
)
private fun initialState(): TokenDetailsUM = TokenDetailsUM(
topAppBarUM = TokenDetailsTopAppBarUM(
titleState = TitleState.Simple(tokenName = "Tether"),
subtitle = stringReference("USDT"),
onBackClick = {},
menuItems = persistentListOf(),
),
balanceBlockUM = mockk<TokenDetailsBalanceBlockUM>(relaxed = true),
notifications = persistentListOf(),
marketPriceBlockState = mockk<MarketPriceBlockState>(relaxed = true),
earnBlockState = null,
pullToRefreshConfig = mockk<PullToRefreshConfig>(relaxed = true),
isBalanceHidden = false,
isMarketPriceAvailable = false,
addFundsUM = AddFundsUM.Loading,
transferUM = TransferUM.Loading,
zeroBalanceActionsUM = ZeroBalanceActionsUM.Loading,
)
}

View file

@ -0,0 +1,231 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer
import com.google.common.truth.Truth.assertThat
import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig
import com.tangem.core.ui.components.marketprice.MarketPriceBlockState
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason
import com.tangem.domain.tokens.model.TokenActionsState
import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents
import com.tangem.feature.tokendetails.presentation.tokendetails.state.AddFundsUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM.TitleState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TransferUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.ZeroBalanceActionsUM
import io.mockk.mockk
import io.mockk.verify
import kotlinx.collections.immutable.persistentListOf
import org.junit.jupiter.api.Test
class UpdateZeroBalanceActionsTransformerTest {
private val clickIntents: TokenDetailsClickIntents = mockk(relaxed = true)
@Test
fun `GIVEN actions without Buy Swap or Receive WHEN transform THEN state is unchanged`() {
// GIVEN
val transformer = createTransformer(
actions = listOf(TokenActionsState.ActionState.Send(ScenarioUnavailabilityReason.None)),
)
val state = initialState()
// WHEN
val result = transformer.transform(state)
// THEN
assertThat(result).isSameInstanceAs(state)
assertThat(result.zeroBalanceActionsUM).isInstanceOf(ZeroBalanceActionsUM.Loading::class.java)
}
@Test
fun `GIVEN all three actions available WHEN transform THEN Content carries all rows enabled`() {
// GIVEN
val transformer = createTransformer(
actions = listOf(
TokenActionsState.ActionState.Buy(ScenarioUnavailabilityReason.None),
TokenActionsState.ActionState.Swap(ScenarioUnavailabilityReason.None, false),
TokenActionsState.ActionState.Receive(ScenarioUnavailabilityReason.None),
),
)
// WHEN
val result = transformer.transform(initialState())
// THEN
val content = result.zeroBalanceActionsUM as ZeroBalanceActionsUM.Content
assertThat(content.buy?.isEnabled).isTrue()
assertThat(content.swap?.isEnabled).isTrue()
assertThat(content.receive?.isEnabled).isTrue()
}
@Test
fun `GIVEN Swap with unavailability reason WHEN transform THEN Swap row stays visible but disabled`() {
// GIVEN — when Swap carries any non-None unavailability reason the row must stay visible
// (layout keeps three slots) but render disabled; click is gated at the row level
// via isEnabled = false. A None reason still produces an enabled row (see test above).
val transformer = createTransformer(
actions = listOf(
TokenActionsState.ActionState.Buy(ScenarioUnavailabilityReason.None),
TokenActionsState.ActionState.Swap(ScenarioUnavailabilityReason.UsedOutdatedData, false),
TokenActionsState.ActionState.Receive(ScenarioUnavailabilityReason.None),
),
)
// WHEN
val result = transformer.transform(initialState())
// THEN
val content = result.zeroBalanceActionsUM as ZeroBalanceActionsUM.Content
assertThat(content.swap).isNotNull()
assertThat(content.swap?.isEnabled).isFalse()
assertThat(content.buy?.isEnabled).isTrue()
assertThat(content.receive?.isEnabled).isTrue()
}
@Test
fun `GIVEN missing actions WHEN transform THEN absent rows are null`() {
// GIVEN
val transformer = createTransformer(
actions = listOf(TokenActionsState.ActionState.Buy(ScenarioUnavailabilityReason.None)),
)
// WHEN
val result = transformer.transform(initialState())
// THEN
val content = result.zeroBalanceActionsUM as ZeroBalanceActionsUM.Content
assertThat(content.buy).isNotNull()
assertThat(content.swap).isNull()
assertThat(content.receive).isNull()
}
@Test
fun `GIVEN Buy row WHEN onClick invoked THEN buy click is dispatched with reason`() {
// GIVEN
val transformer = createTransformer(
actions = listOf(TokenActionsState.ActionState.Buy(ScenarioUnavailabilityReason.None)),
)
// WHEN
val content = transformer.transform(initialState()).zeroBalanceActionsUM as ZeroBalanceActionsUM.Content
content.buy!!.onClick()
// THEN
verify(exactly = 1) { clickIntents.onBuyClick(ScenarioUnavailabilityReason.None) }
}
@Test
fun `GIVEN disabled Swap WHEN onClick invoked THEN swap click receives the unavailability reason`() {
// GIVEN — Row.onClick is always wired; UI gating (isEnabled=false) decides whether it fires.
// This test guards the wiring: when the row IS invoked, the reason is forwarded so callers
// could decide to show the unavailability dialog if they ever drop the UI gating.
val transformer = createTransformer(
actions = listOf(
TokenActionsState.ActionState.Swap(ScenarioUnavailabilityReason.UsedOutdatedData, false),
),
)
// WHEN
val content = transformer.transform(initialState()).zeroBalanceActionsUM as ZeroBalanceActionsUM.Content
content.swap!!.onClick()
// THEN
verify(exactly = 1) { clickIntents.onSwapToClick(ScenarioUnavailabilityReason.UsedOutdatedData) }
}
@Test
fun `GIVEN Receive row WHEN long-clicked THEN copy address is dispatched`() {
// GIVEN
val transformer = createTransformer(
actions = listOf(TokenActionsState.ActionState.Receive(ScenarioUnavailabilityReason.None)),
)
// WHEN
val content = transformer.transform(initialState()).zeroBalanceActionsUM as ZeroBalanceActionsUM.Content
content.receive!!.onLongClick!!()
// THEN
verify(exactly = 1) { clickIntents.onCopyAddress() }
}
@Test
fun `GIVEN actions WHEN transform THEN unrelated state fields are preserved`() {
// GIVEN
val state = initialState()
val transformer = createTransformer(
actions = listOf(TokenActionsState.ActionState.Buy(ScenarioUnavailabilityReason.None)),
)
// WHEN
val result = transformer.transform(state)
// THEN
assertThat(result.addFundsUM).isSameInstanceAs(state.addFundsUM)
assertThat(result.transferUM).isSameInstanceAs(state.transferUM)
assertThat(result.balanceBlockUM).isSameInstanceAs(state.balanceBlockUM)
assertThat(result.topAppBarUM).isSameInstanceAs(state.topAppBarUM)
}
@Test
fun `GIVEN action with loading marker reason WHEN transform THEN that row is marked isLoading`() {
// GIVEN — Swap commonly arrives with DataLoading while networkSource is still CACHE.
// The Swap row stays in Content but with isLoading=true so the UI keeps the spinner.
val transformer = createTransformer(
actions = listOf(
TokenActionsState.ActionState.Buy(ScenarioUnavailabilityReason.None),
TokenActionsState.ActionState.Swap(ScenarioUnavailabilityReason.DataLoading, false),
TokenActionsState.ActionState.Receive(ScenarioUnavailabilityReason.None),
),
)
// WHEN
val result = transformer.transform(initialState())
// THEN
val content = result.zeroBalanceActionsUM as ZeroBalanceActionsUM.Content
assertThat(content.swap?.isLoading).isTrue()
assertThat(content.swap?.isEnabled).isFalse()
assertThat(content.buy?.isLoading).isFalse()
assertThat(content.receive?.isLoading).isFalse()
}
@Test
fun `GIVEN row WHEN not clicked THEN no callbacks fire`() {
// GIVEN
val transformer = createTransformer(
actions = listOf(TokenActionsState.ActionState.Buy(ScenarioUnavailabilityReason.None)),
)
// WHEN
transformer.transform(initialState())
// THEN
verify(exactly = 0) { clickIntents.onBuyClick(any()) }
}
private fun createTransformer(actions: List<TokenActionsState.ActionState>) = UpdateZeroBalanceActionsTransformer(
actions = actions,
clickIntents = clickIntents,
)
private fun initialState(): TokenDetailsUM = TokenDetailsUM(
topAppBarUM = TokenDetailsTopAppBarUM(
titleState = TitleState.Simple(tokenName = "Tether"),
subtitle = stringReference("USDT"),
onBackClick = {},
menuItems = persistentListOf(),
),
balanceBlockUM = mockk<TokenDetailsBalanceBlockUM>(relaxed = true),
notifications = persistentListOf(),
marketPriceBlockState = mockk<MarketPriceBlockState>(relaxed = true),
earnBlockState = null,
pullToRefreshConfig = mockk<PullToRefreshConfig>(relaxed = true),
isBalanceHidden = false,
isMarketPriceAvailable = false,
addFundsUM = AddFundsUM.Loading,
transferUM = TransferUM.Loading,
zeroBalanceActionsUM = ZeroBalanceActionsUM.Loading,
)
}