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

@ -0,0 +1,174 @@
package com.tangem.common.ui.tokenaction
import androidx.annotation.DrawableRes
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.ds.row.TangemRowContainer
import com.tangem.core.ui.ds.row.TangemRowLayoutId
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.haptic.HapticManager
import com.tangem.core.ui.haptic.TangemHapticEffect
import com.tangem.core.ui.res.LocalHapticManager
import com.tangem.core.ui.res.TangemTheme
private const val ACTION_BACKGROUND_ALPHA = .1f
/**
* Single-row token action ("Buy", "Receive", etc.) with accent icon, title, description and a
* customizable tail. Used in bottom sheets like Get Token / Add Funds and Add To Portfolio.
*
* @param iconRes leading 20dp icon drawn over an accent-colored circle
* @param title row primary text
* @param description row secondary text
* @param onClick single-click callback; row is non-interactive if `null`
* @param onLongClick long-press callback; pass `null` to disable long-press
* @param isEnabled when `false`, the row uses disabled-tier colors and ignores clicks
* @param tailContent content placed at the row's end. Defaults to a chevron-right icon.
*/
@Composable
fun TokenActionRow(
@DrawableRes iconRes: Int,
title: TextReference,
description: TextReference,
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null,
onLongClick: (() -> Unit)? = null,
isEnabled: Boolean = true,
tailContent: @Composable () -> Unit = { DefaultTokenActionRowChevron(isEnabled = isEnabled) },
) {
val hapticManager = LocalHapticManager.current
val accentColor = accentColor(isEnabled)
TangemRowContainer(
modifier = modifier
.background(
color = TangemTheme.colors2.surface.level3,
shape = RoundedCornerShape(TangemTheme.dimens2.x5),
)
.clickableWithHaptic(
onClick = onClick.takeIf { isEnabled },
onLongClick = onLongClick.takeIf { isEnabled },
hapticManager = hapticManager,
),
) {
LeadingIcon(iconRes = iconRes, accentColor = accentColor)
Text(
modifier = Modifier.layoutId(TangemRowLayoutId.START_TOP),
text = title.resolveReference(),
style = TangemTheme.typography2.bodyMedium16,
color = titleColor(isEnabled),
)
Text(
modifier = Modifier.layoutId(TangemRowLayoutId.START_BOTTOM),
text = description.resolveReference(),
style = TangemTheme.typography2.captionMedium12,
color = descriptionColor(isEnabled),
)
Tail { tailContent() }
}
}
@Composable
private fun LeadingIcon(@DrawableRes iconRes: Int, accentColor: Color) {
Box(
modifier = Modifier
.layoutId(TangemRowLayoutId.HEAD)
.padding(end = TangemTheme.dimens2.x3)
.size(40.dp)
.background(
color = accentColor.copy(alpha = ACTION_BACKGROUND_ALPHA),
shape = CircleShape,
),
contentAlignment = Alignment.Center,
) {
Icon(
modifier = Modifier.size(20.dp),
imageVector = ImageVector.vectorResource(id = iconRes),
contentDescription = null,
tint = accentColor,
)
}
}
@Composable
private fun Tail(content: @Composable () -> Unit) {
Box(
modifier = Modifier
.layoutId(TangemRowLayoutId.TAIL)
.padding(start = TangemTheme.dimens2.x2)
.size(24.dp),
contentAlignment = Alignment.Center,
) {
content()
}
}
@Composable
private fun accentColor(isEnabled: Boolean): Color = if (isEnabled) {
TangemTheme.colors2.graphic.status.accent
} else {
TangemTheme.colors2.graphic.neutral.quaternary
}
@Composable
private fun titleColor(isEnabled: Boolean): Color = if (isEnabled) {
TangemTheme.colors2.text.neutral.primary
} else {
TangemTheme.colors2.text.status.disabled
}
@Composable
private fun descriptionColor(isEnabled: Boolean): Color = if (isEnabled) {
TangemTheme.colors2.text.neutral.secondary
} else {
TangemTheme.colors2.text.status.disabled
}
@OptIn(ExperimentalFoundationApi::class)
private fun Modifier.clickableWithHaptic(
onClick: (() -> Unit)?,
onLongClick: (() -> Unit)?,
hapticManager: HapticManager,
): Modifier {
if (onClick == null) return this
return combinedClickable(
onClick = hapticManager.withHaptic(TangemHapticEffect.View.SegmentTick, onClick),
onLongClick = onLongClick?.let { hapticManager.withHaptic(TangemHapticEffect.View.LongPress, it) },
)
}
private fun HapticManager.withHaptic(effect: TangemHapticEffect, action: () -> Unit): () -> Unit = {
perform(effect)
action()
}
@Composable
private fun DefaultTokenActionRowChevron(isEnabled: Boolean) {
Icon(
modifier = Modifier.size(24.dp),
imageVector = ImageVector.vectorResource(R.drawable.ic_chevron_small_right_24),
tint = if (isEnabled) {
TangemTheme.colors2.graphic.neutral.tertiary
} else {
TangemTheme.colors2.graphic.neutral.quaternary
},
contentDescription = null,
)
}

View file

@ -39,6 +39,7 @@ fun SecondaryTangemButton(buttonUM: TangemButtonUM, modifier: Modifier = Modifie
isLoading = buttonUM.isLoading,
size = buttonUM.size,
shape = buttonUM.shape,
onLongClick = buttonUM.onLongClick,
)
}
@ -68,6 +69,7 @@ fun SecondaryTangemButton(
isLoading: Boolean = false,
size: TangemButtonSize = TangemButtonSize.X15,
shape: TangemButtonShape = TangemButtonShape.Default,
onLongClick: (() -> Unit)? = null,
) {
val backgroundModifier = if (isEnabled) {
Modifier.background(TangemTheme.colors2.button.backgroundSecondary)
@ -93,6 +95,7 @@ fun SecondaryTangemButton(
isLoading = isLoading,
size = size,
iconPosition = iconPosition,
onLongClick = onLongClick,
)
}

View file

@ -67,12 +67,13 @@ internal fun TangemButtonInternal(
hasPadding: Boolean = true,
contentColor: Color = TangemTheme.colors2.text.neutral.primary,
size: TangemButtonSize = TangemButtonSize.X15,
onLongClick: (() -> Unit)? = null,
) {
ProvideButtonRippleConfiguration {
Box(
modifier = modifier
.testTag(BaseButtonTestTags.BUTTON)
.clickableSingle(enabled = isEnabled, onClick = onClick, role = Role.Button)
.buttonClickable(isEnabled = isEnabled, onClick = onClick, onLongClick = onLongClick)
.heightIn(min = size.toHeightDp())
.conditionalCompose(text == null) {
width(size.toHeightDp())
@ -181,6 +182,19 @@ private fun ButtonContent(
}
}
private fun Modifier.buttonClickable(isEnabled: Boolean, onClick: () -> Unit, onLongClick: (() -> Unit)?): Modifier {
return if (onLongClick != null) {
combinedClickableSingle(
enabled = isEnabled,
role = Role.Button,
onClick = onClick,
onLongClick = onLongClick,
)
} else {
clickableSingle(enabled = isEnabled, onClick = onClick, role = Role.Button)
}
}
@Composable
private inline fun ProvideButtonRippleConfiguration(crossinline content: @Composable () -> Unit) {
CompositionLocalProvider(

View file

@ -17,6 +17,7 @@ import com.tangem.core.ui.extensions.TextReference
* @param shape TangemButtonShape defining the shape of the button.
* @param type TangemButtonType defining the style type of the button.
* @param onClick Lambda to be invoked when the button is clicked.
* @param onLongClick Lambda to be invoked when the button is long-clicked.
*
[REDACTED_AUTHOR]
*/
@ -32,6 +33,7 @@ data class TangemButtonUM(
val shape: TangemButtonShape = TangemButtonShape.Default,
val type: TangemButtonType,
val onClick: () -> Unit,
val onLongClick: (() -> Unit)? = null,
)
/** Enum class representing the style types of Tangem buttons */

View file

@ -60,6 +60,7 @@ fun ActionButtons(buttons: ImmutableList<TangemButtonUM>, modifier: Modifier = M
onClick = button.onClick,
isEnabled = button.isEnabled,
shape = TangemButtonShape.Rounded,
onLongClick = button.onLongClick,
)
Text(
text = button.text.orEmpty().resolveReference(),

View file

@ -3,6 +3,7 @@ package com.tangem.core.ui.extensions
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
@ -37,6 +38,31 @@ fun Modifier.clickableSingle(
)
}
/**
* Combined clickable modifier that debounces multiple [onClick] events in a short period of time.
* Mirrors [clickableSingle] but also exposes [onLongClick]; long-press is not debounced.
*/
fun Modifier.combinedClickableSingle(
enabled: Boolean = true,
onClickLabel: String? = null,
role: Role? = null,
onLongClickLabel: String? = null,
onLongClick: (() -> Unit)? = null,
onClick: () -> Unit,
) = composed {
val multipleEventsCutter = remember { MultipleClickPreventer.get() }
Modifier.combinedClickable(
enabled = enabled,
onClickLabel = onClickLabel,
role = role,
onLongClickLabel = onLongClickLabel,
onLongClick = onLongClick,
onClick = { multipleEventsCutter.processEvent { onClick() } },
indication = LocalIndication.current,
interactionSource = remember { MutableInteractionSource() },
)
}
/**
* Conditionally applies a modifier based on a boolean condition.
*/

View file

@ -61,4 +61,7 @@ sealed class ScenarioUnavailabilityReason {
enum class WithdrawalScenario {
SELL, SEND // TODO staking create&process STAKING
}
}
}
val ScenarioUnavailabilityReason.isLoading: Boolean
get() = this == ScenarioUnavailabilityReason.DataLoading || this is ScenarioUnavailabilityReason.ExpressLoading

View file

@ -1,13 +1,9 @@
package com.tangem.features.commonfeatures.impl.addtoportfolio.ui
import android.content.res.Configuration
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
@ -15,9 +11,6 @@ import androidx.compose.runtime.key
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
@ -26,6 +19,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
import com.tangem.common.ui.markets.action.QuickActionUM
import com.tangem.common.ui.markets.action.QuickActions
import com.tangem.common.ui.tokenaction.TokenActionRow
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.currency.icon.CurrencyIcon
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
@ -36,13 +30,10 @@ import com.tangem.core.ui.ds.button.TangemButtonShape
import com.tangem.core.ui.ds.button.TangemButtonSize
import com.tangem.core.ui.ds.image.DeviceIconUM
import com.tangem.core.ui.ds.image.TangemDeviceIcon
import com.tangem.core.ui.ds.row.TangemRowContainer
import com.tangem.core.ui.ds.row.TangemRowLayoutId
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.formatStyled
import com.tangem.core.ui.format.bigdecimal.price
import com.tangem.core.ui.haptic.TangemHapticEffect
import com.tangem.core.ui.res.*
import com.tangem.features.commonfeatures.impl.R
import com.tangem.features.commonfeatures.impl.addtoportfolio.ui.state.PortfolioBadgeUM
@ -52,8 +43,6 @@ import kotlinx.collections.immutable.persistentListOf
import java.math.BigDecimal
import java.util.UUID
private const val ACTION_BACKGROUND_ALPHA = .1f
@Composable
internal fun TokenActionsContentV2(state: TokenActionsUM, modifier: Modifier = Modifier) {
Column(
@ -72,10 +61,13 @@ internal fun TokenActionsContentV2(state: TokenActionsUM, modifier: Modifier = M
) {
state.quickActions.actions.fastForEach { actionUM ->
key(actionUM.title) {
ActionRow(
state = actionUM,
TokenActionRow(
iconRes = actionUM.icon,
title = actionUM.title,
description = actionUM.description,
onClick = { state.quickActions.onQuickActionClick(actionUM) },
onLongClick = { state.quickActions.onQuickActionLongClick(actionUM) },
onLongClick = { state.quickActions.onQuickActionLongClick(actionUM) }
.takeIf { actionUM.isLongClickAvailable },
)
}
}
@ -95,76 +87,6 @@ internal fun TokenActionsContentV2(state: TokenActionsUM, modifier: Modifier = M
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun ActionRow(
state: QuickActionUM,
onClick: () -> Unit,
onLongClick: (() -> Unit),
modifier: Modifier = Modifier,
) {
val hapticManager = LocalHapticManager.current
val onLongClickInternal = {
hapticManager.perform(TangemHapticEffect.View.LongPress)
onLongClick()
}
TangemRowContainer(
modifier = modifier
.combinedClickable(
onLongClick = onLongClickInternal.takeIf { state.isLongClickAvailable },
onClick = {
hapticManager.perform(TangemHapticEffect.View.SegmentTick)
onClick()
},
)
.background(
color = TangemTheme.colors2.surface.level3,
shape = RoundedCornerShape(TangemTheme.dimens2.x5),
),
) {
Box(
modifier = Modifier
.layoutId(TangemRowLayoutId.HEAD)
.padding(end = TangemTheme.dimens2.x3)
.size(40.dp)
.background(
color = TangemTheme.colors2.graphic.status.accent.copy(alpha = ACTION_BACKGROUND_ALPHA),
shape = CircleShape,
),
contentAlignment = Alignment.Center,
) {
Icon(
modifier = Modifier.size(20.dp),
imageVector = ImageVector.vectorResource(id = state.icon),
contentDescription = null,
tint = TangemTheme.colors2.graphic.status.accent,
)
}
Text(
modifier = Modifier.layoutId(TangemRowLayoutId.START_TOP),
text = state.title.resolveReference(),
style = TangemTheme.typography2.bodyMedium16,
color = TangemTheme.colors2.text.neutral.primary,
)
Text(
modifier = Modifier.layoutId(TangemRowLayoutId.START_BOTTOM),
text = state.description.resolveReference(),
style = TangemTheme.typography2.captionMedium12,
color = TangemTheme.colors2.text.neutral.secondary,
)
Icon(
modifier = Modifier
.layoutId(TangemRowLayoutId.TAIL)
.padding(start = TangemTheme.dimens2.x2)
.size(24.dp),
imageVector = ImageVector.vectorResource(R.drawable.ic_chevron_small_right_24),
tint = TangemTheme.colors2.graphic.neutral.tertiary,
contentDescription = null,
)
}
}
@Composable
private fun TokenHeader(
addedToken: TokenItemState,

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