Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-09 16:41:06 +03:00
parent bfe877736c
commit 4d6afd7afc
7 changed files with 325 additions and 5 deletions

View file

@ -109,6 +109,7 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.Q
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.SetYieldSupplyBalanceTransformer
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
@ -470,6 +471,7 @@ internal class TokenDetailsModel @Inject constructor(
yieldSupplyGetRewardsBalanceUseCase(status = status, appCurrency = selectedAppCurrencyFlow.value)
.onEach { formatted ->
uiState.value = stateFactory.getStateWithUpdatedYieldSupplyDisplayBalance(formatted)
redesignStateController.update(SetYieldSupplyBalanceTransformer(formatted))
}
.flowOn(dispatchers.main)
.launchIn(modelScope)
@ -479,6 +481,7 @@ internal class TokenDetailsModel @Inject constructor(
uiState.value = stateFactory.getStateWithUpdatedYieldSupplyDisplayBalance(
YieldSupplyRewardBalance.empty(),
)
redesignStateController.update(SetYieldSupplyBalanceTransformer(YieldSupplyRewardBalance.empty()))
}
}

View file

@ -37,6 +37,8 @@ internal sealed class TokenDetailsBalanceBlockUM {
val displayFiatBalanceAvailable: TextReference?,
val isBalanceFlickering: Boolean,
val isBalanceZero: Boolean,
val displayYieldSupplyFiatBalance: String? = null,
val displayYieldSupplyCryptoBalance: String? = null,
) : TokenDetailsBalanceBlockUM() {
val displayCryptoBalance: TextReference

View file

@ -86,6 +86,9 @@ internal class SetBalanceTransformer(
val totalCryptoAmount = computeTotal(status.value.amount, stakingCryptoAmount)
val isYieldSupplyActive = status.value.yieldSupplyStatus?.isActive == true
val prevContent = prev as? TokenDetailsBalanceBlockUM.Content
return TokenDetailsBalanceBlockUM.Content(
addFundsButton = prev.addFundsButton,
swapButton = prev.swapButton,
@ -108,6 +111,17 @@ internal class SetBalanceTransformer(
},
isBalanceFlickering = status.value.sources.total == StatusSource.CACHE,
isBalanceZero = totalCryptoAmount.isNullOrZero(),
// Preserve the ticking yield balance produced by SetYieldSupplyBalanceTransformer across status updates
displayYieldSupplyFiatBalance = if (isYieldSupplyActive) {
prevContent?.displayYieldSupplyFiatBalance
} else {
null
},
displayYieldSupplyCryptoBalance = if (isYieldSupplyActive) {
prevContent?.displayYieldSupplyCryptoBalance
} else {
null
},
)
}

View file

@ -0,0 +1,27 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer
import com.tangem.domain.yield.supply.models.YieldSupplyRewardBalance
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM
import com.tangem.utils.transformer.Transformer
/**
* Pushes the ticking yield supply balance (emitted every tick by [YieldSupplyGetRewardsBalanceUseCase])
* into the redesign [TokenDetailsBalanceBlockUM.Content], so the balance increments in real time.
*/
internal class SetYieldSupplyBalanceTransformer(
private val yieldSupplyRewardBalance: YieldSupplyRewardBalance,
) : Transformer<TokenDetailsUM> {
override fun transform(prevState: TokenDetailsUM): TokenDetailsUM {
val balanceBlockUM = prevState.balanceBlockUM
if (balanceBlockUM !is TokenDetailsBalanceBlockUM.Content) return prevState
return prevState.copy(
balanceBlockUM = balanceBlockUM.copy(
displayYieldSupplyFiatBalance = yieldSupplyRewardBalance.fiatBalance,
displayYieldSupplyCryptoBalance = yieldSupplyRewardBalance.cryptoBalance,
),
)
}
}

View file

@ -11,7 +11,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
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.text.TextStyle
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.Preview
@ -23,10 +25,12 @@ import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.TextShimmer
import com.tangem.core.ui.components.currency.icon.CurrencyIcon
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.text.TextAnimatedCounter
import com.tangem.core.ui.ds.button.TangemButtonType
import com.tangem.core.ui.ds.button.TangemButtonUM
import com.tangem.core.ui.ds.button.action.ActionButtons
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.orMaskWithStars
import com.tangem.core.ui.extensions.resolveAnnotatedReference
import com.tangem.core.ui.extensions.resolveReference
@ -42,6 +46,9 @@ import kotlinx.collections.immutable.persistentListOf
private val CurrencyIconSize: Dp = 70.dp
private val NetworkBadgeSize: Dp = 24.dp
/** OpenType "tabular figures" feature — makes every digit the same width to prevent horizontal jitter. */
private const val TABULAR_FIGURES_FEATURE = "tnum"
@Composable
internal fun TokenDetailsBalanceBlock(
balanceBlockUM: TokenDetailsBalanceBlockUM,
@ -124,20 +131,60 @@ private fun ContentBody(state: TokenDetailsBalanceBlockUM.Content, isBalanceHidd
}
}
SpacerH(TangemTheme.dimens2.x2)
Text(
AnimatedBalance(
modifier = Modifier.testTag(TokenDetailsScreenTestTags.BALANCE_FIAT),
text = state.displayFiatBalance.orMaskWithStars(isBalanceHidden).resolveAnnotatedReference(),
yieldBalance = state.displayYieldSupplyFiatBalance,
fallbackBalance = state.displayFiatBalance,
style = TangemTheme.typography2.titleRegular44,
color = TangemTheme.colors2.text.neutral.primary,
isBalanceHidden = isBalanceHidden,
)
SpacerH(TangemTheme.dimens2.x2_5)
Text(
text = state.displayCryptoBalance.orMaskWithStars(isBalanceHidden).resolveAnnotatedReference(),
AnimatedBalance(
yieldBalance = state.displayYieldSupplyCryptoBalance,
fallbackBalance = state.displayCryptoBalance,
style = TangemTheme.typography2.bodySemibold16,
color = TangemTheme.colors2.text.neutral.secondary,
isBalanceHidden = isBalanceHidden,
)
}
/**
* Renders a balance that animates digit-by-digit ([TextAnimatedCounter]) while a ticking yield supply
* value is present, and falls back to a plain [Text] otherwise (or when the balance is hidden).
*/
@Composable
private fun AnimatedBalance(
yieldBalance: String?,
fallbackBalance: TextReference,
style: TextStyle,
color: Color,
isBalanceHidden: Boolean,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = TangemTheme.dimens2.x6),
contentAlignment = Alignment.Center,
) {
if (yieldBalance != null && !isBalanceHidden) {
TextAnimatedCounter(
text = yieldBalance,
// Tabular figures keep every digit the same width, so the centered balance doesn't
// jitter horizontally as digits roll during the increment animation.
style = style.copy(color = color, fontFeatureSettings = TABULAR_FIGURES_FEATURE),
)
} else {
Text(
text = fallbackBalance.orMaskWithStars(isBalanceHidden).resolveAnnotatedReference(),
style = style,
color = color,
)
}
}
}
@Composable
private fun LoadingBody() {
Text(

View file

@ -14,6 +14,7 @@ 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.domain.models.yield.supply.YieldSupplyStatus
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
@ -434,6 +435,59 @@ class SetBalanceTransformerTest {
// endregion
// region Yield supply balance
@Test
fun `GIVEN yield active AND prev has yield balances WHEN transform THEN yield balances preserved`() {
// GIVEN
val status = createStatus(loadedValue(yieldSupplyStatus = activeYieldSupplyStatus()))
val transformer = createTransformer(status)
val prev = contentWithYieldBalances(fiat = "$21,000.12", crypto = "10.500001 ETH")
val state = initialState().copy(balanceBlockUM = prev)
// WHEN
val result = transformer.transform(state)
// THEN
val content = result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content
assertThat(content.displayYieldSupplyFiatBalance).isEqualTo("$21,000.12")
assertThat(content.displayYieldSupplyCryptoBalance).isEqualTo("10.500001 ETH")
}
@Test
fun `GIVEN yield inactive AND prev has yield balances WHEN transform THEN yield balances are cleared`() {
// GIVEN
val status = createStatus(loadedValue(yieldSupplyStatus = null))
val transformer = createTransformer(status)
val prev = contentWithYieldBalances(fiat = "$21,000.12", crypto = "10.500001 ETH")
val state = initialState().copy(balanceBlockUM = prev)
// WHEN
val result = transformer.transform(state)
// THEN
val content = result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content
assertThat(content.displayYieldSupplyFiatBalance).isNull()
assertThat(content.displayYieldSupplyCryptoBalance).isNull()
}
@Test
fun `GIVEN yield active AND prev is not Content WHEN transform THEN yield balances are null`() {
// GIVEN — prev is the default Loading block, so there are no yield balances to preserve yet
val status = createStatus(loadedValue(yieldSupplyStatus = activeYieldSupplyStatus()))
val transformer = createTransformer(status)
// WHEN
val result = transformer.transform(initialState())
// THEN
val content = result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content
assertThat(content.displayYieldSupplyFiatBalance).isNull()
assertThat(content.displayYieldSupplyCryptoBalance).isNull()
}
// endregion
// region Unrelated fields preserved
@Test
@ -473,6 +527,7 @@ class SetBalanceTransformerTest {
fiatAmount: BigDecimal = BigDecimal("21000"),
fiatRate: BigDecimal = BigDecimal("2000"),
stakingBalance: StakingBalance? = null,
yieldSupplyStatus: YieldSupplyStatus? = null,
sources: CryptoCurrencyStatus.Sources = CryptoCurrencyStatus.Sources(),
): CryptoCurrencyStatus.Loaded = CryptoCurrencyStatus.Loaded(
amount = amount,
@ -480,13 +535,39 @@ class SetBalanceTransformerTest {
fiatRate = fiatRate,
priceChange = BigDecimal("2.5"),
stakingBalance = stakingBalance,
yieldSupplyStatus = null,
yieldSupplyStatus = yieldSupplyStatus,
hasCurrentNetworkTransactions = false,
pendingTransactions = emptySet(),
networkAddress = mockk(relaxed = true),
sources = sources,
)
private fun activeYieldSupplyStatus(): YieldSupplyStatus = YieldSupplyStatus(
isActive = true,
isInitialized = true,
isAllowedToSpend = true,
effectiveProtocolBalance = null,
)
private fun contentWithYieldBalances(
fiat: String?,
crypto: String?,
): TokenDetailsBalanceBlockUM.Content = TokenDetailsBalanceBlockUM.Content(
addFundsButton = placeholderButton(),
swapButton = placeholderButton(),
transferButton = placeholderButton(),
tokenBalanceTypeUM = TokenBalanceTypeUM.Single,
currencyIconState = CurrencyIconState.Loading,
displayCryptoBalanceAll = stringReference(""),
displayFiatBalanceAll = stringReference(""),
displayCryptoBalanceAvailable = null,
displayFiatBalanceAvailable = null,
isBalanceFlickering = false,
isBalanceZero = false,
displayYieldSupplyFiatBalance = fiat,
displayYieldSupplyCryptoBalance = crypto,
)
private fun noQuoteValue(): CryptoCurrencyStatus.NoQuote = CryptoCurrencyStatus.NoQuote(
amount = BigDecimal("5.0"),
stakingBalance = null,

View file

@ -0,0 +1,146 @@
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.domain.yield.supply.models.YieldSupplyRewardBalance
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
class SetYieldSupplyBalanceTransformerTest {
@Test
fun `GIVEN Content state WHEN transform THEN yield balances are set from reward balance`() {
// GIVEN
val state = stateWith(contentBlock())
val transformer = SetYieldSupplyBalanceTransformer(
YieldSupplyRewardBalance(fiatBalance = "$21,000.12", cryptoBalance = "10.500001 ETH"),
)
// WHEN
val result = transformer.transform(state)
// THEN
val content = result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content
assertThat(content.displayYieldSupplyFiatBalance).isEqualTo("$21,000.12")
assertThat(content.displayYieldSupplyCryptoBalance).isEqualTo("10.500001 ETH")
}
@Test
fun `GIVEN empty reward balance WHEN transform on Content THEN yield balances are nulled`() {
// GIVEN
val state = stateWith(
contentBlock().copy(
displayYieldSupplyFiatBalance = "$21,000.12",
displayYieldSupplyCryptoBalance = "10.500001 ETH",
),
)
val transformer = SetYieldSupplyBalanceTransformer(YieldSupplyRewardBalance.empty())
// WHEN
val result = transformer.transform(state)
// THEN
val content = result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content
assertThat(content.displayYieldSupplyFiatBalance).isNull()
assertThat(content.displayYieldSupplyCryptoBalance).isNull()
}
@Test
fun `GIVEN Loading block WHEN transform THEN state is unchanged`() {
// GIVEN
val state = stateWith(loadingBlock())
val transformer = SetYieldSupplyBalanceTransformer(
YieldSupplyRewardBalance(fiatBalance = "$1", cryptoBalance = "1 ETH"),
)
// WHEN
val result = transformer.transform(state)
// THEN
assertThat(result).isSameInstanceAs(state)
}
@Test
fun `GIVEN Error block WHEN transform THEN state is unchanged`() {
// GIVEN
val state = stateWith(errorBlock())
val transformer = SetYieldSupplyBalanceTransformer(
YieldSupplyRewardBalance(fiatBalance = "$1", cryptoBalance = "1 ETH"),
)
// WHEN
val result = transformer.transform(state)
// THEN
assertThat(result).isSameInstanceAs(state)
}
private fun contentBlock(): TokenDetailsBalanceBlockUM.Content = TokenDetailsBalanceBlockUM.Content(
addFundsButton = placeholderButton(),
swapButton = placeholderButton(),
transferButton = placeholderButton(),
tokenBalanceTypeUM = TokenBalanceTypeUM.Single,
currencyIconState = CurrencyIconState.Loading,
displayCryptoBalanceAll = stringReference("10.5 ETH"),
displayFiatBalanceAll = stringReference("$21,000.00"),
displayCryptoBalanceAvailable = null,
displayFiatBalanceAvailable = null,
isBalanceFlickering = false,
isBalanceZero = false,
)
private fun loadingBlock(): TokenDetailsBalanceBlockUM.Loading = TokenDetailsBalanceBlockUM.Loading(
addFundsButton = placeholderButton(),
swapButton = placeholderButton(),
transferButton = placeholderButton(),
tokenBalanceTypeUM = TokenBalanceTypeUM.Single,
currencyIconState = CurrencyIconState.Loading,
)
private fun errorBlock(): TokenDetailsBalanceBlockUM.Error = TokenDetailsBalanceBlockUM.Error(
addFundsButton = placeholderButton(),
swapButton = placeholderButton(),
transferButton = placeholderButton(),
tokenBalanceTypeUM = TokenBalanceTypeUM.Single,
currencyIconState = CurrencyIconState.Loading,
)
private fun stateWith(balanceBlockUM: TokenDetailsBalanceBlockUM): TokenDetailsUM = TokenDetailsUM(
topAppBarUM = TokenDetailsTopAppBarUM(
titleState = TitleState.Simple(tokenName = ""),
subtitle = stringReference(""),
onBackClick = {},
menuItems = persistentListOf(),
),
balanceBlockUM = balanceBlockUM,
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 placeholderButton(): TangemButtonUM = TangemButtonUM(
text = stringReference(""),
type = TangemButtonType.Secondary,
onClick = {},
)
}