diff --git a/common/ui/src/main/java/com/tangem/common/ui/earn/EarnBlock.kt b/common/ui/src/main/java/com/tangem/common/ui/earn/EarnBlock.kt index 9f024aa283..c4be2f621e 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/earn/EarnBlock.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/earn/EarnBlock.kt @@ -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( diff --git a/common/ui/src/main/java/com/tangem/common/ui/earn/EarnBlockUM.kt b/common/ui/src/main/java/com/tangem/common/ui/earn/EarnBlockUM.kt index 3739f2591b..c0b0a4ad33 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/earn/EarnBlockUM.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/earn/EarnBlockUM.kt @@ -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 } diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index f4809d2272..048d7cbf3b 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -1517,6 +1517,8 @@ APY Rewards automatically accumulate in your staking balance daily. Rewards are compounded to your staking balance. Funds earned: %s + Rewards are compounded to your staking balance. + Funds earned: %s Available Average Reward Rate How Staking Works? diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt index e5734666af..691cb5f5dc 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt @@ -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, ), ) diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsStateController.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsStateController.kt index ce9ba16f45..c1e5c1fafb 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsStateController.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsStateController.kt @@ -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 = uiState + .map { it.isBalanceHidden } + .distinctUntilChanged() + fun update(function: (TokenDetailsUM) -> TokenDetailsUM) { uiState.update(function = function) } diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateStakingNotificationTransformer.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateStakingNotificationTransformer.kt index a23cd14f49..dedf153f73 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateStakingNotificationTransformer.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateStakingNotificationTransformer.kt @@ -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 { 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, ) } diff --git a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateStakingNotificationTransformerTest.kt b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateStakingNotificationTransformerTest.kt index 31b1d632f2..054e33a23d 100644 --- a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateStakingNotificationTransformerTest.kt +++ b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateStakingNotificationTransformerTest.kt @@ -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(relaxed = true) { - every { rawId } returns "solana" + every { rawId } returns networkRawId every { isTestnet } returns false } val currency = mockk(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(relaxed = true) val value = mockk(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(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(relaxed = true), pullToRefreshConfig = mockk(relaxed = true), - isBalanceHidden = false, + isBalanceHidden = isBalanceHidden, isMarketPriceAvailable = false, addFundsUM = AddFundsUM.Loading, transferUM = TransferUM.Loading,