Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-15 20:44:57 +03:00
parent ebf28a282b
commit cf02ade4c5
7 changed files with 227 additions and 54 deletions

View file

@ -125,15 +125,21 @@ private fun EarnBlockContent(state: EarnBlockUM.Content, modifier: Modifier = Mo
.padding(end = TangemTheme.dimens2.x2),
)
val subtitle = state.subtitleUM
if (subtitle is EarnBlockUM.SubtitleUM.Text) {
EarnBlockSubtitle(
val subtitleModifier = Modifier
.layoutId(TangemRowLayoutId.START_BOTTOM)
.padding(end = TangemTheme.dimens2.x2)
when (val subtitle = state.subtitleUM) {
is EarnBlockUM.SubtitleUM.Text -> EarnBlockSubtitle(
subtitle = subtitle,
type = state.type,
modifier = Modifier
.layoutId(TangemRowLayoutId.START_BOTTOM)
.padding(end = TangemTheme.dimens2.x2),
modifier = subtitleModifier,
)
is EarnBlockUM.SubtitleUM.AccentedText -> EarnBlockAccentedSubtitle(
subtitle = subtitle,
type = state.type,
modifier = subtitleModifier,
)
null -> Unit
}
EarnBlockTrailing(type = state.type, trailingUM = state.trailingUM, onClick = state.onClick)
@ -253,26 +259,24 @@ private fun EarnBlockTrailing(type: Type, trailingUM: EarnBlockUM.TrailingUM?, o
)
}
is EarnBlockUM.TrailingUM.Balance -> {
if (!trailingUM.isBalanceHidden) {
val fiatModifier = Modifier.layoutId(TangemRowLayoutId.END_TOP).let {
if (type == Type.Staking) it.testTag(TokenDetailsScreenTestTags.STAKING_FIAT_AMOUNT) else it
}
val cryptoModifier = Modifier.layoutId(TangemRowLayoutId.END_BOTTOM).let {
if (type == Type.Staking) it.testTag(TokenDetailsScreenTestTags.STAKING_TOKEN_AMOUNT) else it
}
Text(
text = trailingUM.fiatValue.resolveAnnotatedReference(),
style = TangemTheme.typography2.bodySemibold16,
color = TangemTheme.colors2.text.neutral.primary,
modifier = fiatModifier,
)
Text(
text = trailingUM.cryptoValue.resolveReference(),
style = TangemTheme.typography2.captionMedium12,
color = TangemTheme.colors2.text.neutral.secondary,
modifier = cryptoModifier,
)
val fiatModifier = Modifier.layoutId(TangemRowLayoutId.END_TOP).let {
if (type == Type.Staking) it.testTag(TokenDetailsScreenTestTags.STAKING_FIAT_AMOUNT) else it
}
val cryptoModifier = Modifier.layoutId(TangemRowLayoutId.END_BOTTOM).let {
if (type == Type.Staking) it.testTag(TokenDetailsScreenTestTags.STAKING_TOKEN_AMOUNT) else it
}
Text(
text = trailingUM.fiatValue.orMaskWithStars(trailingUM.isBalanceHidden).resolveAnnotatedReference(),
style = TangemTheme.typography2.bodySemibold16,
color = TangemTheme.colors2.text.neutral.primary,
modifier = fiatModifier,
)
Text(
text = trailingUM.cryptoValue.orMaskWithStars(trailingUM.isBalanceHidden).resolveReference(),
style = TangemTheme.typography2.captionMedium12,
color = TangemTheme.colors2.text.neutral.secondary,
modifier = cryptoModifier,
)
}
null -> Unit
}
@ -325,6 +329,29 @@ private fun EarnBlockSubtitle(subtitle: EarnBlockUM.SubtitleUM.Text, type: Type,
}
}
@Composable
private fun EarnBlockAccentedSubtitle(
subtitle: EarnBlockUM.SubtitleUM.AccentedText,
type: Type,
modifier: Modifier = Modifier,
) {
val baseText = subtitle.text.resolveReference()
val accentText = subtitle.accent.resolveReference()
val accentColor = type.accentText()
Text(
text = buildAnnotatedString {
append(baseText)
if (baseText.isNotEmpty() && !baseText.last().isWhitespace()) append(' ')
withStyle(SpanStyle(color = accentColor)) {
append(accentText)
}
},
style = subtitle.style.textStyle,
color = TangemTheme.colors2.text.neutral.tertiary,
modifier = modifier,
)
}
@Composable
private fun EarnBlockIcon(type: Type, iconUM: EarnBlockUM.IconUM, modifier: Modifier = Modifier) {
Box(

View file

@ -72,6 +72,12 @@ sealed interface EarnBlockUM {
val loader: Loader? = null,
) : SubtitleUM
data class AccentedText(
val text: TextReference,
val accent: TextReference,
val style: Style,
) : SubtitleUM
data class Loader(val tone: LoaderTone)
enum class Style { Large, Small }

View file

@ -1517,6 +1517,8 @@
<string name="staking_details_apy">APY</string>
<string name="staking_details_auto_claiming_rewards_daily_text">Rewards automatically accumulate in your staking balance daily.</string>
<string name="staking_details_autocompound_rewards_earned">Rewards are compounded to your staking balance. Funds earned: %s</string>
<string name="staking_details_autocompound_rewards_compounded">Rewards are compounded to your staking balance.</string>
<string name="staking_details_autocompound_funds_earned">Funds earned: %s</string>
<string name="staking_details_available">Available</string>
<string name="staking_details_average_reward_rate">Average Reward Rate</string>
<string name="staking_details_banner_text">How Staking Works?</string>

View file

@ -1484,13 +1484,15 @@ internal class TokenDetailsModel @Inject constructor(
flow2 = availabilityFlow,
flow3 = entryInfoFlow,
flow4 = selectedAppCurrencyFlow,
) { status, availability, entryInfo, appCurrency ->
flow5 = redesignStateController.isBalanceHidden,
) { status, availability, entryInfo, appCurrency, isBalanceHidden ->
redesignStateController.update(
UpdateStakingNotificationTransformer(
cryptoCurrencyStatus = status,
stakingAvailability = availability,
stakingEntryInfo = entryInfo,
appCurrency = appCurrency,
isBalanceHidden = isBalanceHidden,
clickIntents = this,
),
)

View file

@ -13,8 +13,11 @@ 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
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import javax.inject.Inject
@ -26,6 +29,10 @@ internal class TokenDetailsStateController @Inject constructor() {
val value: TokenDetailsUM get() = uiState.value
val isBalanceHidden: Flow<Boolean> = uiState
.map { it.isBalanceHidden }
.distinctUntilChanged()
fun update(function: (TokenDetailsUM) -> TokenDetailsUM) {
uiState.update(function = function)
}

View file

@ -5,6 +5,7 @@ import com.tangem.common.getRewardStakingBalance
import com.tangem.common.getTotalStakingBalance
import com.tangem.common.ui.earn.EarnBlockUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.orMaskWithStars
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.wrappedList
@ -34,11 +35,12 @@ internal class UpdateStakingNotificationTransformer(
private val stakingAvailability: StakingAvailability,
private val stakingEntryInfo: StakingEntryInfo?,
private val appCurrency: AppCurrency,
private val isBalanceHidden: Boolean,
private val clickIntents: TokenDetailsClickIntents,
) : Transformer<TokenDetailsUM> {
override fun transform(prevState: TokenDetailsUM): TokenDetailsUM {
return prevState.copy(earnBlockState = buildEarnBlock(prevState.isBalanceHidden))
return prevState.copy(earnBlockState = buildEarnBlock(isBalanceHidden))
}
private fun buildEarnBlock(isBalanceHidden: Boolean): EarnBlockUM? {
@ -56,12 +58,12 @@ internal class UpdateStakingNotificationTransformer(
iconUM = EarnBlockUM.IconUM.Plain(iconRes = CoreUiR.drawable.ic_staking_disable_40),
titleUM = EarnBlockUM.TitleUM(
text = resourceReference(CoreResR.string.common_staking),
style = EarnBlockUM.TitleUM.Style.Small,
style = EarnBlockUM.TitleUM.Style.Large,
tone = EarnBlockUM.TitleUM.Tone.Disabled,
),
subtitleUM = EarnBlockUM.SubtitleUM.Text(
text = resourceReference(CoreResR.string.staking_notification_network_error_text),
style = EarnBlockUM.SubtitleUM.Style.Large,
style = EarnBlockUM.SubtitleUM.Style.Small,
tone = EarnBlockUM.SubtitleUM.Tone.Disabled,
),
trailingUM = null,
@ -117,12 +119,12 @@ internal class UpdateStakingNotificationTransformer(
iconUM = EarnBlockUM.IconUM.Glowing(iconRes = CoreUiR.drawable.ic_staking_40),
titleUM = EarnBlockUM.TitleUM(
text = resourceReference(id = CoreResR.string.common_staking),
style = EarnBlockUM.TitleUM.Style.Small,
style = EarnBlockUM.TitleUM.Style.Large,
tone = EarnBlockUM.TitleUM.Tone.Primary,
),
subtitleUM = EarnBlockUM.SubtitleUM.Text(
text = stakeAvailableSubtitle(availability.option.displayRewardInfo),
style = EarnBlockUM.SubtitleUM.Style.Large,
style = EarnBlockUM.SubtitleUM.Style.Small,
tone = EarnBlockUM.SubtitleUM.Tone.Disabled,
),
trailingUM = EarnBlockUM.TrailingUM.Button(
@ -165,10 +167,10 @@ internal class UpdateStakingNotificationTransformer(
iconUM = EarnBlockUM.IconUM.Glowing(iconRes = CoreUiR.drawable.ic_staking_40),
titleUM = EarnBlockUM.TitleUM(
text = resourceReference(CoreResR.string.staking_enabled),
style = EarnBlockUM.TitleUM.Style.Small,
style = EarnBlockUM.TitleUM.Style.Large,
tone = EarnBlockUM.TitleUM.Tone.Primary,
),
subtitleUM = getRewardSubtitle(status, rewardFiatAmount),
subtitleUM = getRewardSubtitle(status, rewardFiatAmount, isBalanceHidden),
trailingUM = EarnBlockUM.TrailingUM.Balance(
fiatValue = fiatAmount.formatStyled {
fiat(
@ -196,6 +198,7 @@ internal class UpdateStakingNotificationTransformer(
private fun getRewardSubtitle(
status: CryptoCurrencyStatus,
stakingRewardAmount: BigDecimal?,
isBalanceHidden: Boolean,
): EarnBlockUM.SubtitleUM? {
val blockchainId = status.currency.network.rawId
val isCoin = status.currency.id.isCoin
@ -224,16 +227,20 @@ internal class UpdateStakingNotificationTransformer(
-> return null
RewardBlockType.EthereumEarnedRewards -> {
val cryptoRewardAmount = (stakingBalance as? StakingBalance.Data.P2PEthPool)?.totalRewards
resourceReference(
R.string.staking_details_autocompound_rewards_earned,
wrappedList(
cryptoRewardAmount.format {
crypto(
symbol = status.currency.symbol,
decimals = status.currency.decimals,
)
},
return EarnBlockUM.SubtitleUM.AccentedText(
text = resourceReference(R.string.staking_details_autocompound_rewards_compounded),
accent = resourceReference(
R.string.staking_details_autocompound_funds_earned,
wrappedList(
cryptoRewardAmount.format {
crypto(
symbol = status.currency.symbol,
decimals = status.currency.decimals,
)
}.orMaskWithStars(isBalanceHidden),
),
),
style = EarnBlockUM.SubtitleUM.Style.Small,
)
}
RewardBlockType.RewardsRequirementsError,
@ -241,18 +248,18 @@ internal class UpdateStakingNotificationTransformer(
-> resourceReference(
R.string.staking_details_rewards_to_claim,
wrappedList(
stakingRewardAmount.format { fiat(appCurrency.code, appCurrency.symbol) },
stakingRewardAmount.format { fiat(appCurrency.code, appCurrency.symbol) }
.orMaskWithStars(isBalanceHidden),
),
)
}
val isAccent = rewardBlockType == RewardBlockType.Rewards ||
rewardBlockType == RewardBlockType.RewardsRequirementsError ||
rewardBlockType == RewardBlockType.EthereumEarnedRewards
rewardBlockType == RewardBlockType.RewardsRequirementsError
return EarnBlockUM.SubtitleUM.Text(
text = text,
style = EarnBlockUM.SubtitleUM.Style.Large,
style = EarnBlockUM.SubtitleUM.Style.Small,
tone = if (isAccent) EarnBlockUM.SubtitleUM.Tone.Accent else EarnBlockUM.SubtitleUM.Tone.Disabled,
)
}

View file

@ -4,15 +4,23 @@ import com.google.common.truth.Truth.assertThat
import com.tangem.common.ui.earn.EarnBlockUM
import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig
import com.tangem.core.ui.components.marketprice.MarketPriceBlockState
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.StatusSource
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.BalanceItem
import com.tangem.domain.models.staking.BalanceType
import com.tangem.domain.models.staking.StakingBalance
import com.tangem.domain.models.staking.StakingID
import com.tangem.domain.models.staking.YieldBalanceItem
import com.tangem.domain.models.staking.YieldToken
import com.tangem.domain.staking.model.StakingAvailability
import com.tangem.domain.staking.model.StakingEntryInfo
import com.tangem.domain.staking.model.StakingOption
import com.tangem.utils.StringsSigns.THREE_STARS
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
@ -84,29 +92,114 @@ class UpdateStakingNotificationTransformerTest {
assertThat(content.trailingUM).isInstanceOf(EarnBlockUM.TrailingUM.Button::class.java)
}
@Test
fun `GIVEN active staked balance AND balance hidden WHEN transform THEN trailing balance hidden`() {
// Arrange
val status = buildStatus(
networkRawId = "ethereum",
symbol = "ETH",
isCoin = false,
stakingBalance = stakeKitBalance(staked = BigDecimal("100"), rewards = BigDecimal("5")),
)
val transformer = createTransformer(
availability = availableOption(BigDecimal("4.2")),
entryInfo = StakingEntryInfo(tokenSymbol = "ETH"),
cryptoCurrencyStatus = status,
isBalanceHidden = true,
)
// Act
val result = transformer.transform(initialState())
// Assert
val content = result.earnBlockState as EarnBlockUM.Content
val trailing = content.trailingUM as EarnBlockUM.TrailingUM.Balance
assertThat(trailing.isBalanceHidden).isTrue()
}
@Test
fun `GIVEN rewards to claim AND balance hidden WHEN transform THEN reward amount masked with stars`() {
// Arrange
val status = buildStatus(
networkRawId = "ethereum",
symbol = "ETH",
isCoin = false,
stakingBalance = stakeKitBalance(staked = BigDecimal("100"), rewards = BigDecimal("5")),
)
val transformer = createTransformer(
availability = availableOption(BigDecimal("4.2")),
entryInfo = StakingEntryInfo(tokenSymbol = "ETH"),
cryptoCurrencyStatus = status,
isBalanceHidden = true,
)
// Act
val result = transformer.transform(initialState())
// Assert
val content = result.earnBlockState as EarnBlockUM.Content
val subtitle = content.subtitleUM as EarnBlockUM.SubtitleUM.Text
assertThat(rewardFormatArg(subtitle.text)).isEqualTo(THREE_STARS)
}
@Test
fun `GIVEN rewards to claim AND balance visible WHEN transform THEN reward amount not masked`() {
// Arrange
val status = buildStatus(
networkRawId = "ethereum",
symbol = "ETH",
isCoin = false,
stakingBalance = stakeKitBalance(staked = BigDecimal("100"), rewards = BigDecimal("5")),
)
val transformer = createTransformer(
availability = availableOption(BigDecimal("4.2")),
entryInfo = StakingEntryInfo(tokenSymbol = "ETH"),
cryptoCurrencyStatus = status,
isBalanceHidden = false,
)
// Act
val result = transformer.transform(initialState())
// Assert
val content = result.earnBlockState as EarnBlockUM.Content
val subtitle = content.subtitleUM as EarnBlockUM.SubtitleUM.Text
assertThat(rewardFormatArg(subtitle.text)).isNotEqualTo(THREE_STARS)
}
private fun rewardFormatArg(text: TextReference): Any? =
(text as TextReference.Res).formatArgs.data.firstOrNull()
private fun createTransformer(
availability: StakingAvailability,
entryInfo: StakingEntryInfo?,
cryptoCurrencyStatus: CryptoCurrencyStatus = buildStatus(),
isBalanceHidden: Boolean = false,
) = UpdateStakingNotificationTransformer(
cryptoCurrencyStatus = buildStatus(),
cryptoCurrencyStatus = cryptoCurrencyStatus,
stakingAvailability = availability,
stakingEntryInfo = entryInfo,
appCurrency = AppCurrency.Default,
isBalanceHidden = isBalanceHidden,
clickIntents = clickIntents,
)
private fun buildStatus(): CryptoCurrencyStatus {
private fun buildStatus(
networkRawId: String = "solana",
symbol: String = "SOL",
isCoin: Boolean = true,
stakingBalance: StakingBalance = mockk(relaxed = true),
): CryptoCurrencyStatus {
val network = mockk<Network>(relaxed = true) {
every { rawId } returns "solana"
every { rawId } returns networkRawId
every { isTestnet } returns false
}
val currency = mockk<CryptoCurrency.Coin>(relaxed = true) {
every { symbol } returns "SOL"
every { this@mockk.symbol } returns symbol
every { decimals } returns 9
every { this@mockk.network } returns network
every { id.isCoin } returns true
every { id.isCoin } returns isCoin
}
val stakingBalance = mockk<StakingBalance>(relaxed = true)
val value = mockk<CryptoCurrencyStatus.Value>(relaxed = true) {
every { this@mockk.stakingBalance } returns stakingBalance
every { fiatRate } returns BigDecimal.ONE
@ -115,6 +208,35 @@ class UpdateStakingNotificationTransformerTest {
return CryptoCurrencyStatus(currency = currency, value = value)
}
/** Builds an active StakeKit balance with the given [staked] and [rewards] amounts. */
private fun stakeKitBalance(staked: BigDecimal, rewards: BigDecimal): StakingBalance.Data.StakeKit {
val stakingId = StakingID(integrationId = "ethereum-eth-native-staking", address = "0xabc")
return StakingBalance.Data.StakeKit(
stakingId = stakingId,
source = StatusSource.ACTUAL,
balance = YieldBalanceItem(
integrationId = stakingId.integrationId,
items = listOf(
balanceItem(type = BalanceType.STAKED, amount = staked),
balanceItem(type = BalanceType.REWARDS, amount = rewards),
),
),
)
}
private fun balanceItem(type: BalanceType, amount: BigDecimal): BalanceItem = BalanceItem(
groupId = "group",
token = YieldToken.ETH,
type = type,
amount = amount,
rawCurrencyId = null,
validatorAddress = null,
date = null,
pendingActions = emptyList(),
pendingActionsConstraints = emptyList(),
isPending = false,
)
private fun availableOption(apy: BigDecimal): StakingAvailability.Available {
val option = mockk<StakingOption>(relaxed = true) {
every { this@mockk.apy } returns apy
@ -122,7 +244,7 @@ class UpdateStakingNotificationTransformerTest {
return StakingAvailability.Available(option = option)
}
private fun initialState(): TokenDetailsUM = TokenDetailsUM(
private fun initialState(isBalanceHidden: Boolean = false): TokenDetailsUM = TokenDetailsUM(
topAppBarUM = TokenDetailsTopAppBarUM(
titleState = TitleState.Simple(tokenName = "Solana"),
subtitle = stringReference("Solana network"),
@ -134,7 +256,7 @@ class UpdateStakingNotificationTransformerTest {
earnBlockState = null,
marketPriceBlockState = mockk<MarketPriceBlockState>(relaxed = true),
pullToRefreshConfig = mockk<PullToRefreshConfig>(relaxed = true),
isBalanceHidden = false,
isBalanceHidden = isBalanceHidden,
isMarketPriceAvailable = false,
addFundsUM = AddFundsUM.Loading,
transferUM = TransferUM.Loading,