Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-15 11:16:31 +05:00
parent 9b25b6a28d
commit eec91a9b4d
14 changed files with 1010 additions and 47 deletions

View file

@ -1,15 +1,18 @@
package com.tangem.features.foryou.impl.entity
import androidx.annotation.StringRes
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.ds.row.token.TangemTokenRowUM
import com.tangem.core.ui.ds.tabs.TangemSegmentUM
import com.tangem.core.ui.ds.tabs.TangemSegmentedPickerUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.features.foryou.impl.components.state.MarketChartUM
import com.tangem.features.foryou.impl.model.ForYouNotification
import kotlinx.collections.immutable.ImmutableList
internal data class ForYouUM(
val portfolioReviewUM: PortfolioReviewUM,
val earnOpportunities: EarnOpportunitiesUM,
val notifications: ImmutableList<ForYouNotification>,
)
@ -31,6 +34,23 @@ internal sealed interface PortfolioReviewUM {
) : PortfolioReviewUM
}
@Immutable
internal sealed interface EarnOpportunitiesUM {
val tokenList: ImmutableList<ForYouTokenListItemUM>
data class Loading(
override val tokenList: ImmutableList<ForYouTokenListItemUM>,
) : EarnOpportunitiesUM
data class Content(
override val tokenList: ImmutableList<ForYouTokenListItemUM>,
@param:StringRes val subtitleRes: Int,
val potentialReward: TextReference?,
val potentialRewardType: TextReference?,
) : EarnOpportunitiesUM
}
@Immutable
internal data class ForYouTokenListItemUM(
val tokenRowUM: TangemTokenRowUM,

View file

@ -15,6 +15,7 @@ import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.foryou.ForYouComponent
import com.tangem.features.foryou.impl.components.state.MarketChartUM
import com.tangem.features.foryou.impl.entity.EarnOpportunitiesUM
import com.tangem.features.foryou.impl.entity.ForYouTokenListItemUM
import com.tangem.features.foryou.impl.entity.ForYouUM
import com.tangem.features.foryou.impl.entity.PortfolioReviewUM
@ -45,6 +46,22 @@ internal class ForYouModel @Inject constructor(
field = MutableStateFlow<ForYouUM>(
ForYouUM(
notifications = persistentListOf(),
earnOpportunities = EarnOpportunitiesUM.Loading(
tokenList = buildList<ForYouTokenListItemUM> {
repeat(4) { index ->
add(
ForYouTokenListItemUM(
tokenRowUM = TangemTokenRowUM.Loading(
id = index.toString(),
),
tokenList = persistentListOf(),
isExpanded = false,
isExpandable = false,
),
)
}
}.toPersistentList(),
),
portfolioReviewUM = PortfolioReviewUM.Loading(
marketChartUM = MarketChartUM.NoData,
tokenList = buildList<ForYouTokenListItemUM> {

View file

@ -1,42 +0,0 @@
package com.tangem.features.foryou.impl.model.converter
import com.tangem.core.ui.ds.badge.TangemBadgeColor
import com.tangem.core.ui.ds.badge.TangemBadgeSize
import com.tangem.core.ui.ds.badge.TangemBadgeType
import com.tangem.core.ui.ds.badge.TangemBadgeUM
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.utils.extensions.isZero
import java.math.BigDecimal
import java.math.RoundingMode
/**
* Formatting helpers shared by the For You portfolio-review converters.
*
* Kept null-safe so that non-[CryptoCurrencyStatus.Loaded] states (which carry no fiat amount) degrade
* to `null` instead of throwing.
*/
/**
* Cross-network grouping key for the portfolio review: the same asset on different networks (e.g. USDC
* on Solana and Ethereum) shares its `rawCurrencyId`, so they group under a single item. Custom tokens
* have no raw id and fall back to their unique currency id, staying in their own group.
*/
internal fun CryptoCurrencyStatus.forYouGroupKey(): String = currency.id.rawCurrencyId?.value ?: currency.id.value
/**
* Computes this fiat amount as a share of [totalFiatBalance]. Returns `null` when the share cannot be
* computed (no amount, or a zero total / amount).
*/
internal fun BigDecimal?.toForYouPercent(totalFiatBalance: BigDecimal): BigDecimal? {
if (this == null || totalFiatBalance.isZero() || isZero()) return null
return divide(totalFiatBalance, RoundingMode.HALF_UP)
}
// TODO For You: replace this placeholder with the real price-change badge once the design is wired.
internal fun forYouPlaceholderBadge(): TangemBadgeUM = TangemBadgeUM(
text = stringReference("Positive"),
size = TangemBadgeSize.X4,
type = TangemBadgeType.Tinted,
color = TangemBadgeColor.Green,
)

View file

@ -4,10 +4,7 @@ import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToI
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds.row.token.TangemTokenRowUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.pluralReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.percent
@ -131,7 +128,11 @@ internal class ForYouTokenListConverter(
headIconUM = TangemIconUM.Currency(CurrencyIconState.Empty()),
titleUM = TangemTokenRowUM.TitleUM.Content(text = resourceReference(R.string.common_other)),
subtitleUM = TangemTokenRowUM.SubtitleUM.Content(
text = pluralReference(R.plurals.market_chart_assets_android, otherAssets.count()),
text = pluralReference(
id = R.plurals.market_chart_assets_android,
count = otherAssets.count(),
formatArgs = wrappedList(otherAssets.count()),
),
),
topEndContentUM = TangemTokenRowUM.EndContentUM.Content(
text = stringReference(

View file

@ -0,0 +1,78 @@
package com.tangem.features.foryou.impl.model.converter
import com.tangem.core.ui.ds.badge.TangemBadgeColor
import com.tangem.core.ui.ds.badge.TangemBadgeSize
import com.tangem.core.ui.ds.badge.TangemBadgeType
import com.tangem.core.ui.ds.badge.TangemBadgeUM
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.utils.extensions.isZero
import java.math.BigDecimal
import java.math.RoundingMode
/** Number of suggested top-earn tokens shown in the earn-opportunities block. */
internal const val FOR_YOU_TOP_EARN_TOKENS_COUNT = 5
/** Batch size for getting top earn tokens in single and only page */
internal const val TOP_EARN_TOKENS_BATCH_SIZE = 30
/** Divisor converting backend percent values (5.5) to fractions (0.055). */
internal val PERCENT_BASE = BigDecimal("100")
/**
* Cross-network grouping key for the portfolio review: the same asset on different networks (e.g. USDC
* on Solana and Ethereum) shares its `rawCurrencyId`, so they group under a single item. Custom tokens
* have no raw id and fall back to their unique currency id, staying in their own group.
*/
internal fun CryptoCurrencyStatus.forYouGroupKey(): String = currency.id.rawCurrencyId?.value ?: currency.id.value
/**
* Matching key between a portfolio currency and a top-earn suggestion: the same asset
* (`rawCurrencyId`, falling back to the unique id for custom tokens) on the same network.
*/
internal fun CryptoCurrency.forYouEarnAssetKey(): Pair<String, String> =
(id.rawCurrencyId?.value ?: id.value) to network.rawId
/**
* Computes this fiat amount as a share of [totalFiatBalance]. Returns `null` when the share cannot be
* computed (no amount, or a zero total / amount).
*/
internal fun BigDecimal?.toForYouPercent(totalFiatBalance: BigDecimal): BigDecimal? {
if (this == null || totalFiatBalance.isZero() || isZero()) return null
return divide(totalFiatBalance, RoundingMode.HALF_UP)
}
// TODO For You: replace this placeholder with the real price-change badge once the design is wired.
internal fun forYouPlaceholderBadge(): TangemBadgeUM = TangemBadgeUM(
text = stringReference("Positive"),
size = TangemBadgeSize.X4,
type = TangemBadgeType.Tinted,
color = TangemBadgeColor.Green,
)
/**
* Earn rate resolved for a portfolio currency.
*
* @property isActive whether the user already earns on the token (active yield supply or stake)
* @property apy rate as a fraction (0.05 = 5%)
* @property potentialRewards projected yearly reward in fiat (`fiatAmount * apy`), `null` when unknown
*/
internal data class EarnApyInfo(
val isActive: Boolean,
val apy: BigDecimal?,
val potentialRewards: BigDecimal?,
)
/**
* Earn-eligible currencies of one account with their resolved rates.
*
* @property accountPotentialReward sum of [EarnApyInfo.potentialRewards] over [earnCurrencues];
* accounts are ordered by it, descending
*/
internal data class EarnOpportunities(
val account: Account.CryptoPortfolio,
val earnCurrencues: Map<CryptoCurrencyStatus, EarnApyInfo>,
val accountPotentialReward: BigDecimal,
)

View file

@ -0,0 +1,213 @@
package com.tangem.features.foryou.impl.model.converter.earnOpportunities
import com.tangem.domain.account.models.AccountStatusList
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.account.filterCryptoPortfolio
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.currency.yieldSupplyKey
import com.tangem.domain.models.earn.EarnTopToken
import com.tangem.domain.models.staking.StakingBalance
import com.tangem.domain.staking.model.StakingAvailability
import com.tangem.domain.staking.model.StakingOption
import com.tangem.domain.staking.model.common.RewardInfo
import com.tangem.domain.staking.model.common.RewardType
import com.tangem.domain.staking.model.optionOrNull
import com.tangem.features.foryou.impl.entity.EarnOpportunitiesUM
import com.tangem.features.foryou.impl.model.converter.EarnApyInfo
import com.tangem.features.foryou.impl.model.converter.EarnOpportunities
import com.tangem.features.foryou.impl.model.converter.PERCENT_BASE
import com.tangem.lib.crypto.BlockchainUtils
import com.tangem.utils.converter.Converter
import com.tangem.utils.extensions.orZero
import com.tangem.utils.isNullOrZero
import java.math.BigDecimal
import java.math.RoundingMode
/**
* Builds the For You earn-opportunities section. For every portfolio currency it resolves an earn rate
* (see [resolveEarnApy]) and keeps only tokens that could earn (positive fiat balance) or already do
* (active yield supply / stake).
*
* The section state is then picked from the result:
* - nothing is earn-eligible [ForYouEarnOpportunitiesNoTokensConverter] (suggests [topEarnTokens]);
* - every eligible token already earns [ForYouEarnOpportunitiesTokensActiveConverter]
* (suggests [topEarnTokens] the user is not earning on yet);
* - otherwise [ForYouEarnOpportunitiesPotentialRewardsConverter] (per-account potential rewards,
* accounts sorted by reward descending).
*/
@Suppress("LongParameterList")
internal class ForYouEarnOpportunitiesConverter(
private val appCurrency: AppCurrency,
private val isAccountsModeEnabled: Boolean,
private val expandedAssetIds: Set<String>,
private val expandClick: (assetId: String) -> Unit,
private val yieldSupplyAvailability: Map<String, BigDecimal>,
private val yieldStakingAvailability: Map<CryptoCurrency, StakingAvailability>,
private val topEarnTokens: EarnTopToken?,
) : Converter<AccountStatusList?, EarnOpportunitiesUM> {
override fun convert(value: AccountStatusList?): EarnOpportunitiesUM {
val data = value?.accountStatuses
?.filterCryptoPortfolio()
?.asSequence()
?.mapNotNull { cryptoAccountStatus ->
val tokenList =
cryptoAccountStatus.flattenCurrencies().mapNotNull { cryptoCurrencyStatus ->
val earn = resolveEarnApy(
cryptoCurrencyStatus = cryptoCurrencyStatus,
yieldModuleApyMap = yieldSupplyAvailability,
stakingApyMap = yieldStakingAvailability,
)
if (earn == null || cryptoCurrencyStatus.value.fiatAmount.isNullOrZero() && !earn.isActive) {
return@mapNotNull null
}
cryptoCurrencyStatus to earn
}
if (tokenList.isEmpty()) return@mapNotNull null
val accountPotentialReward = tokenList.sumOf { (_, earn) ->
earn.potentialRewards.orZero()
}
EarnOpportunities(
account = cryptoAccountStatus.account,
earnCurrencues = tokenList.toMap(),
accountPotentialReward = accountPotentialReward,
)
}
?.sortedByDescending { it.accountPotentialReward }
.orEmpty().toList()
return when {
data.isEmpty() -> {
ForYouEarnOpportunitiesNoTokensConverter(topEarnTokens).convert(data)
}
data.all { earn -> earn.earnCurrencues.all { entry -> entry.value.isActive } } -> {
ForYouEarnOpportunitiesTokensActiveConverter(topEarnTokens).convert(data)
}
else -> {
ForYouEarnOpportunitiesPotentialRewardsConverter(
appCurrency = appCurrency,
isAccountsModeEnabled = isAccountsModeEnabled,
expandedAssetIds = expandedAssetIds,
expandClick = expandClick,
).convert(data)
}
}
}
/**
* Resolves the earn rate for a currency; yield supply takes precedence over staking. The returned
* [EarnApyInfo.apy] is a fraction (0.05 = 5%): yield APY arrives from the backend in percent and is
* scaled down by [PERCENT_BASE]. Returns `null` when the token cannot earn at all.
*/
private fun resolveEarnApy(
cryptoCurrencyStatus: CryptoCurrencyStatus,
yieldModuleApyMap: Map<String, BigDecimal>,
stakingApyMap: Map<CryptoCurrency, StakingAvailability>,
): EarnApyInfo? {
val token = cryptoCurrencyStatus.currency as? CryptoCurrency.Token
if (token != null && yieldModuleApyMap.isNotEmpty()) {
val yieldSupplyApy = yieldModuleApyMap.entries.firstOrNull { (tokenId, _) ->
tokenId.equals(
other = token.yieldSupplyKey(),
ignoreCase = BlockchainUtils.isCaseInsensitiveContractAddress(token.network.rawId),
)
}?.value
if (yieldSupplyApy != null) {
val isActive = cryptoCurrencyStatus.value.yieldSupplyStatus?.isActive == true
val apy = yieldSupplyApy.divide(PERCENT_BASE, RoundingMode.HALF_UP)
return EarnApyInfo(
isActive = isActive,
potentialRewards = cryptoCurrencyStatus.value.fiatAmount?.multiply(apy),
apy = apy,
)
}
}
if (stakingApyMap.isNotEmpty()) {
val stakingInfo = findStakingRate(
currencyStatus = cryptoCurrencyStatus,
stakingApyMap = stakingApyMap,
)
if (stakingInfo.rate != null) {
return EarnApyInfo(
isActive = stakingInfo.isActive,
apy = stakingInfo.rate,
potentialRewards = cryptoCurrencyStatus.value.fiatAmount?.multiply(stakingInfo.rate),
)
}
}
return null
}
/**
* Picks the staking rate to display: for an active StakeKit stake the rate of the validator the user
* actually stakes with (falling back to the best preferred one), otherwise the best preferred
* validator's rate. [StakingAvailability.Full] pools are surfaced only for tokens already staked.
*/
private fun findStakingRate(
currencyStatus: CryptoCurrencyStatus,
stakingApyMap: Map<CryptoCurrency, StakingAvailability>,
): StakingLocalInfo {
val availability = stakingApyMap[currencyStatus.currency]
val option = availability?.optionOrNull
?: return StakingLocalInfo(rate = null, isActive = false, rewardType = null)
val stakingBalance = currencyStatus.value.stakingBalance as? StakingBalance.Data
val stakeKitBalance = stakingBalance as? StakingBalance.Data.StakeKit
val p2pEthPoolBalance = stakingBalance as? StakingBalance.Data.P2PEthPool
val isActive = stakeKitBalance != null || p2pEthPoolBalance != null
// Full = no free capacity: show the badge only for tokens that already have a stake.
if (availability is StakingAvailability.Full && !isActive) {
return StakingLocalInfo(rate = null, isActive = false, rewardType = null)
}
val rateInfo = when (option) {
is StakingOption.P2PEthPool -> {
RewardInfo(
rate = option.apy,
type = RewardType.APY,
)
}
is StakingOption.StakeKit -> if (stakeKitBalance != null) {
val validatorsByAddress = option.yield.validators.associateBy { it.address }
stakeKitBalance.balance.items
.mapNotNull { it.validatorAddress }
.firstNotNullOfOrNull { address ->
validatorsByAddress[address]?.rewardInfo
} ?: option.yield.validators
.filter { it.preferred }
.mapNotNull { validator ->
validator.rewardInfo
}
.maxByOrNull { it.rate }
} else {
option.yield.validators
.filter { it.preferred }
.mapNotNull { validator ->
validator.rewardInfo
}
.maxByOrNull { it.rate }
}
}
return StakingLocalInfo(
rate = rateInfo?.rate,
isActive = isActive,
rewardType = rateInfo?.type,
)
}
private data class StakingLocalInfo(
val rate: BigDecimal?,
val isActive: Boolean,
val rewardType: RewardType?,
)
}

View file

@ -0,0 +1,44 @@
package com.tangem.features.foryou.impl.model.converter.earnOpportunities
import com.tangem.common.ui.R
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.percent
import com.tangem.core.ui.utils.parseBigDecimalOrNull
import com.tangem.domain.models.earn.EarnTopToken
import com.tangem.features.foryou.impl.entity.EarnOpportunitiesUM
import com.tangem.features.foryou.impl.model.converter.EarnOpportunities
import com.tangem.features.foryou.impl.model.converter.FOR_YOU_TOP_EARN_TOKENS_COUNT
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.toPersistentList
/**
* Earn-opportunities state for a portfolio with nothing earn-eligible: suggests the top
* [FOR_YOU_TOP_EARN_TOKENS_COUNT] earn tokens, headed by the best (first) suggestion's yearly rate
* ([EarnOpportunitiesUM.Content.potentialReward]) and its reward type, APR/APY
* ([EarnOpportunitiesUM.Content.potentialRewardType]).
*/
internal class ForYouEarnOpportunitiesNoTokensConverter(
private val topEarnTokens: EarnTopToken?,
) : Converter<List<EarnOpportunities>, EarnOpportunitiesUM> {
override fun convert(value: List<EarnOpportunities>): EarnOpportunitiesUM {
val topEarnTokenList = topEarnTokens?.getOrNull()
?.take(FOR_YOU_TOP_EARN_TOKENS_COUNT)
val topEarnToken = topEarnTokenList?.firstOrNull()?.earnToken
val topEarnApy = topEarnToken?.apy?.parseBigDecimalOrNull()
val rowConverter = ForYouEarnOpportunitiesTopTokenRowConverter()
return EarnOpportunitiesUM.Content(
tokenList = topEarnTokenList
?.map(rowConverter::convert)
.orEmpty()
.toPersistentList(),
subtitleRes = R.string.for_you_earn_opportunities_no_available_tokens,
potentialReward = stringReference(topEarnApy.format { percent() }),
potentialRewardType = topEarnToken?.rewardType?.name?.let(::stringReference),
)
}
}

View file

@ -0,0 +1,121 @@
package com.tangem.features.foryou.impl.model.converter.earnOpportunities
import com.tangem.common.ui.R
import com.tangem.common.ui.account.AccountIconItemStateConverter
import com.tangem.common.ui.account.toUM
import com.tangem.core.ui.components.account.AccountIconSize
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds.row.token.TangemTokenRowUM
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.account.Account
import com.tangem.features.foryou.impl.entity.EarnOpportunitiesUM
import com.tangem.features.foryou.impl.entity.ForYouTokenListItemUM
import com.tangem.features.foryou.impl.model.converter.EarnOpportunities
import com.tangem.utils.StringsSigns
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import java.math.BigDecimal
internal class ForYouEarnOpportunitiesPotentialRewardsConverter(
private val appCurrency: AppCurrency,
private val isAccountsModeEnabled: Boolean,
private val expandedAssetIds: Set<String>,
private val expandClick: (assetId: String) -> Unit,
) : Converter<List<EarnOpportunities>, EarnOpportunitiesUM> {
private val rowConverter = ForYouEarnOpportunitiesTokenRowConverter(appCurrency = appCurrency)
override fun convert(value: List<EarnOpportunities>): EarnOpportunitiesUM {
val totalPotentialReward = value.sumOf { it.accountPotentialReward }
val totalPotentialRewardText = resourceReference(
R.string.for_you_earn_per_year,
wrappedList(
totalPotentialReward.format {
fiat(
fiatCurrencySymbol = appCurrency.symbol,
fiatCurrencyCode = appCurrency.code,
)
},
),
)
return EarnOpportunitiesUM.Content(
tokenList = value.flatMap { earnData ->
if (isAccountsModeEnabled) {
listOf(
ForYouTokenListItemUM(
tokenRowUM = createAssetRow(
account = earnData.account,
potentialReward = earnData.accountPotentialReward,
tokenCount = earnData.earnCurrencues.size,
),
tokenList = rowConverter.convertList(earnData.earnCurrencues.toList())
.toPersistentList(),
isExpanded = earnData.account.accountId.value in expandedAssetIds,
isExpandable = true,
),
)
} else {
earnData.earnCurrencues.map { token ->
ForYouTokenListItemUM(
tokenRowUM = rowConverter.convert(token.toPair()),
tokenList = persistentListOf(),
isExpanded = false,
isExpandable = false,
)
}
}
}.toPersistentList(),
subtitleRes = R.string.for_you_earn_opportunities_tokens_rewards,
potentialReward = totalPotentialRewardText,
potentialRewardType = null,
)
}
private fun createAssetRow(
account: Account.CryptoPortfolio,
potentialReward: BigDecimal?,
tokenCount: Int,
): TangemTokenRowUM {
return TangemTokenRowUM.Content(
id = account.accountId.value,
headIconUM = TangemIconUM.Currency(
currencyIconState = AccountIconItemStateConverter(size = AccountIconSize.RedesignedDefault)
.convert(account),
),
titleUM = TangemTokenRowUM.TitleUM.Content(
text = account.accountName.toUM().value,
),
subtitleUM = TangemTokenRowUM.SubtitleUM.Content(
text = pluralReference(
R.plurals.common_tokens_count,
count = tokenCount,
formatArgs = wrappedList(tokenCount),
),
),
topEndContentUM = TangemTokenRowUM.EndContentUM.Content(
text = combinedReference(
stringReference(StringsSigns.PLUS),
resourceReference(
R.string.for_you_earn_per_year,
wrappedList(
potentialReward.format {
fiat(
fiatCurrencySymbol = appCurrency.symbol,
fiatCurrencyCode = appCurrency.code,
)
},
),
),
),
),
bottomEndContentUM = TangemTokenRowUM.EndContentUM.Empty,
onItemClick = { expandClick(account.accountId.value) },
onItemLongClick = null,
)
}
}

View file

@ -0,0 +1,135 @@
package com.tangem.features.foryou.impl.model.converter.earnOpportunities
import androidx.compose.ui.text.SpanStyle
import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.common.ui.tokens.TokenItemStateConverter.Companion.isFlickering
import com.tangem.core.ui.R
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds.row.token.TangemTokenRowUM
import com.tangem.core.ui.ds.row.token.TangemTokenRowUM.SubtitleUM.Content
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.percent
import com.tangem.core.ui.res.TangemTheme
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.features.foryou.impl.model.converter.EarnApyInfo
import com.tangem.utils.StringsSigns
import com.tangem.utils.converter.Converter
import com.tangem.utils.extensions.orZero
import kotlinx.collections.immutable.toImmutableList
import java.math.BigDecimal
internal class ForYouEarnOpportunitiesTokenRowConverter(
private val appCurrency: AppCurrency,
) : Converter<Pair<CryptoCurrencyStatus, EarnApyInfo>, TangemTokenRowUM> {
private val iconConverter = CryptoCurrencyToIconStateConverter()
override fun convert(value: Pair<CryptoCurrencyStatus, EarnApyInfo>): TangemTokenRowUM {
val (cryptoCurrencyStatus, earnApyInfo) = value
if (cryptoCurrencyStatus.value is CryptoCurrencyStatus.Loading) {
return TangemTokenRowUM.Loading(id = cryptoCurrencyStatus.currency.id.value)
}
val possibleEarnAmount = cryptoCurrencyStatus.value.fiatAmount.orZero().multiply(earnApyInfo.apy.orZero())
return TangemTokenRowUM.Content(
id = cryptoCurrencyStatus.currency.id.value,
headIconUM = TangemIconUM.Currency(iconConverter.convert(cryptoCurrencyStatus)),
titleUM = TangemTokenRowUM.TitleUM.Content(text = stringReference(cryptoCurrencyStatus.currency.name)),
subtitleUM = Content(
text = resourceReference(
R.string.wallet_network_group_title,
wrappedList(cryptoCurrencyStatus.currency.network.name),
),
),
topEndContentUM = toRowTopEnd(cryptoCurrencyStatus, possibleEarnAmount),
bottomEndContentUM = toRowBottomEnd(cryptoCurrencyStatus, earnApyInfo.apy.orZero()),
onItemClick = null,
onItemLongClick = null,
)
}
/** Top-end: fiat total for resolved states, dash / unreachable treatment otherwise. */
private fun toRowTopEnd(
cryptoCurrencyStatus: CryptoCurrencyStatus,
possibleEarnAmount: BigDecimal,
): TangemTokenRowUM.EndContentUM = when (cryptoCurrencyStatus.value) {
CryptoCurrencyStatus.Loading -> TangemTokenRowUM.EndContentUM.Loading
is CryptoCurrencyStatus.Custom,
is CryptoCurrencyStatus.Loaded,
is CryptoCurrencyStatus.NoAccount,
-> {
val possibleEarn = possibleEarnAmount.format {
fiat(
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
)
}
TangemTokenRowUM.EndContentUM.Content(
text = combinedReference(
stringReference(StringsSigns.PLUS),
resourceReference(
R.string.for_you_earn_per_year,
wrappedList(possibleEarn),
),
),
isFlickering = cryptoCurrencyStatus.value.isFlickering(),
startIcons = buildList {
if (cryptoCurrencyStatus.value.sources.total == StatusSource.ONLY_CACHE) {
add(
TangemIconUM.Icon(
iconRes = R.drawable.ic_error_sync_default_24,
tintReference = { TangemTheme.colors3.icon.tertiary },
),
)
}
}.toImmutableList(),
)
}
is CryptoCurrencyStatus.NoQuote,
is CryptoCurrencyStatus.MissedDerivation,
is CryptoCurrencyStatus.NoAmount,
is CryptoCurrencyStatus.Unreachable,
-> TangemTokenRowUM.EndContentUM.Content(text = stringReference(StringsSigns.DASH_SIGN))
}
/** Bottom-end: percentage share for resolved states, no-address / unreachable treatment otherwise. */
private fun toRowBottomEnd(
cryptoCurrencyStatus: CryptoCurrencyStatus,
earnRate: BigDecimal,
): TangemTokenRowUM.EndContentUM = when (cryptoCurrencyStatus.value) {
CryptoCurrencyStatus.Loading -> TangemTokenRowUM.EndContentUM.Loading
is CryptoCurrencyStatus.Custom,
is CryptoCurrencyStatus.Loaded,
is CryptoCurrencyStatus.NoAccount,
-> {
TangemTokenRowUM.EndContentUM.Content(
text = styledStringReference(
value = earnRate.format { percent() },
spanStyleReference = { SpanStyle(color = TangemTheme.colors3.text.status.success) },
),
isFlickering = cryptoCurrencyStatus.value.isFlickering(),
startIcons = buildList {
if (cryptoCurrencyStatus.value.sources.total == StatusSource.ONLY_CACHE) {
add(
TangemIconUM.Icon(
iconRes = R.drawable.ic_error_sync_default_24,
tintReference = { TangemTheme.colors3.icon.tertiary },
),
)
}
}.toImmutableList(),
)
}
is CryptoCurrencyStatus.NoQuote,
is CryptoCurrencyStatus.MissedDerivation,
is CryptoCurrencyStatus.NoAmount,
is CryptoCurrencyStatus.Unreachable,
-> TangemTokenRowUM.EndContentUM.Content(text = stringReference(StringsSigns.DASH_SIGN))
}
}

View file

@ -0,0 +1,45 @@
package com.tangem.features.foryou.impl.model.converter.earnOpportunities
import com.tangem.common.ui.R
import com.tangem.domain.models.earn.EarnTopToken
import com.tangem.features.foryou.impl.entity.EarnOpportunitiesUM
import com.tangem.features.foryou.impl.model.converter.EarnOpportunities
import com.tangem.features.foryou.impl.model.converter.FOR_YOU_TOP_EARN_TOKENS_COUNT
import com.tangem.features.foryou.impl.model.converter.forYouEarnAssetKey
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.toPersistentList
/**
* Earn-opportunities state for a portfolio where every earn-eligible token is already active:
* suggests the top earn tokens the user is not earning on yet (the ones already active are filtered out).
*
* Matching is per asset **and** network (see [forYouEarnAssetKey]), so an asset active on one network
* can still be suggested on another. Active tokens are filtered out before the
* [FOR_YOU_TOP_EARN_TOKENS_COUNT] cap, so exclusions don't shrink the suggestion list while more
* candidates remain in the batch.
*/
internal class ForYouEarnOpportunitiesTokensActiveConverter(
private val topEarnTokens: EarnTopToken?,
) : Converter<List<EarnOpportunities>, EarnOpportunitiesUM> {
override fun convert(value: List<EarnOpportunities>): EarnOpportunitiesUM {
val activeAssetKeys = value
.flatMap { opportunities -> opportunities.earnCurrencues.keys }
.map { status -> status.currency.forYouEarnAssetKey() }
.toSet()
val rowConverter = ForYouEarnOpportunitiesTopTokenRowConverter()
return EarnOpportunitiesUM.Content(
tokenList = topEarnTokens?.getOrNull()
?.filterNot { topToken -> topToken.cryptoCurrency.forYouEarnAssetKey() in activeAssetKeys }
?.take(FOR_YOU_TOP_EARN_TOKENS_COUNT)
?.map(rowConverter::convert)
.orEmpty()
.toPersistentList(),
subtitleRes = R.string.for_you_earn_opportunities_all_tokens_active,
potentialReward = null,
potentialRewardType = null,
)
}
}

View file

@ -0,0 +1,73 @@
package com.tangem.features.foryou.impl.model.converter.earnOpportunities
import com.tangem.common.ui.R
import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds.row.token.TangemTokenRowUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.percent
import com.tangem.domain.models.earn.EarnTokenWithCurrency
import com.tangem.domain.models.earn.EarnType
import com.tangem.features.foryou.impl.entity.ForYouTokenListItemUM
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.persistentListOf
import java.math.BigDecimal
/**
* Maps a top-earn token (an opportunity the user doesn't hold yet) to a For You list item:
* network subtitle, APY top-end and earn-type bottom-end. Shared by the earn-opportunities
* converters that surface suggestions ([ForYouEarnOpportunitiesNoTokensConverter],
* [ForYouEarnOpportunitiesTokensActiveConverter]).
*/
internal class ForYouEarnOpportunitiesTopTokenRowConverter : Converter<EarnTokenWithCurrency, ForYouTokenListItemUM> {
private val iconConverter = CryptoCurrencyToIconStateConverter()
override fun convert(value: EarnTokenWithCurrency): ForYouTokenListItemUM {
val (networkName, earnToken, cryptoCurrency) = value
return ForYouTokenListItemUM(
tokenRowUM = TangemTokenRowUM.Content(
id = cryptoCurrency.id.value,
headIconUM = TangemIconUM.Currency(iconConverter.convert(cryptoCurrency)),
titleUM = TangemTokenRowUM.TitleUM.Content(
text = stringReference(cryptoCurrency.name),
),
subtitleUM = TangemTokenRowUM.SubtitleUM.Content(
text = resourceReference(
R.string.wallet_network_group_title,
wrappedList(networkName),
),
),
topEndContentUM = TangemTokenRowUM.EndContentUM.Content(
text = resourceReference(
R.string.markets_apy_placeholder,
wrappedList(convertPercent(earnToken.apy)),
),
),
bottomEndContentUM = TangemTokenRowUM.EndContentUM.Content(
text = resourceReference(
when (earnToken.type) {
EarnType.STAKING -> R.string.common_staking
EarnType.YIELD -> R.string.common_yield_mode
},
),
),
onItemClick = {},
onItemLongClick = { _, _ -> },
),
tokenList = persistentListOf(),
isExpanded = false,
isExpandable = false,
)
}
private fun convertPercent(value: String): TextReference {
val percent = BigDecimal(value).format { percent(withPercentSign = false) }
return TextReference.Str(percent)
}
}

View file

@ -26,6 +26,7 @@ import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.features.foryou.impl.entity.ForYouUM
import com.tangem.features.foryou.impl.model.ForYouNotification
import com.tangem.features.foryou.impl.ui.preview.ForYouEarnOpportunitiesPreviewData
import com.tangem.features.foryou.impl.ui.preview.ForYouPortfolioReviewPreviewData
import com.tangem.features.promobanners.api.PromoBannersBlockComponent
import kotlinx.collections.immutable.persistentListOf
@ -111,6 +112,7 @@ private class ForYouContentPreviewProvider : PreviewParameterProvider<ForYouUM>
get() = sequenceOf(
ForYouUM(
notifications = persistentListOf(ForYouNotification.UsedOutdatedData),
earnOpportunities = ForYouEarnOpportunitiesPreviewData.tokensRewards,
portfolioReviewUM = ForYouPortfolioReviewPreviewData.reviewContent,
),
)

View file

@ -0,0 +1,157 @@
package com.tangem.features.foryou.impl.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.PlaceholderVerticalAlign
import androidx.compose.ui.text.rememberTextMeasurer
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.core.ui.ds2.shimmers.TangemShimmer
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.features.foryou.impl.R
import com.tangem.features.foryou.impl.entity.EarnOpportunitiesUM
import com.tangem.features.foryou.impl.ui.components.ForYouPortfolioTokenList
import com.tangem.features.foryou.impl.ui.preview.ForYouEarnOpportunitiesPreviewData
private const val INLINE_CONTENT_PADDING_COEF = 2.2f
@Composable
internal fun ForYouEarnOpportunities(earnOpportunitiesUM: EarnOpportunitiesUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier,
) {
Text(
text = stringResourceSafe(R.string.for_you_earn_opportunities),
style = TangemTheme.typography3.heading.small,
color = TangemTheme.colors3.text.secondary,
)
when (earnOpportunitiesUM) {
is EarnOpportunitiesUM.Content -> {
val (textContent, inlineContent) = if (earnOpportunitiesUM.potentialReward != null) {
val potentialReward = earnOpportunitiesUM.potentialReward.resolveReference()
val inlineContent = rememberEarnInlineContent(potentialReward)
val textContent = resourceReference(
earnOpportunitiesUM.subtitleRes,
wrappedList(
annotatedReference {
if (earnOpportunitiesUM.potentialRewardType != null) {
append(earnOpportunitiesUM.potentialRewardType.resolveAnnotatedReference())
appendSpace()
}
appendInlineContent(potentialReward, potentialReward)
},
),
)
textContent to inlineContent
} else {
resourceReference(earnOpportunitiesUM.subtitleRes) to emptyMap()
}
Text(
text = textContent.resolveAnnotatedReference(),
inlineContent = inlineContent,
style = TangemTheme.typography3.heading.small,
color = TangemTheme.colors3.text.primary,
)
}
is EarnOpportunitiesUM.Loading -> {
TangemShimmer(
style = TangemTheme.typography3.heading.small,
)
}
}
ForYouPortfolioTokenList(
tokenList = earnOpportunitiesUM.tokenList,
modifier = Modifier.padding(top = 8.dp),
)
}
}
@Composable
private fun rememberEarnInlineContent(potentialReward: String): Map<String, InlineTextContent> {
val density = LocalDensity.current
val textMeasurer = rememberTextMeasurer()
val horizontalPadding = 2.dp
val potentialStyle = TangemTheme.typography3.heading.small
val potentialLabelBgWidthSp = remember(potentialReward) {
val textLayoutMeasure = textMeasurer.measure(
text = potentialReward,
style = potentialStyle,
)
with(density) {
(textLayoutMeasure.size.width.toDp() + (horizontalPadding.value * INLINE_CONTENT_PADDING_COEF).dp).toSp()
}
}
return remember(potentialReward, potentialLabelBgWidthSp) {
mapOf(
potentialReward to InlineTextContent(
Placeholder(
width = potentialLabelBgWidthSp,
height = potentialStyle.lineHeight,
placeholderVerticalAlign = PlaceholderVerticalAlign.Center,
),
) {
Text(
text = potentialReward,
style = potentialStyle,
color = TangemTheme.colors3.text.primary,
modifier = Modifier
.background(
color = TangemTheme.colors3.icon.brand,
shape = RoundedCornerShape(6.dp),
)
.padding(horizontal = horizontalPadding),
)
},
)
}
}
// region Preview
@Composable
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun ForYouEarnOpportunities_Preview(
@PreviewParameter(ForYouEarnOpportunitiesPreviewProvider::class) params: EarnOpportunitiesUM,
) {
TangemThemePreviewRedesign {
ForYouEarnOpportunities(
earnOpportunitiesUM = params,
modifier = Modifier.background(TangemTheme.colors3.bg.primary),
)
}
}
private class ForYouEarnOpportunitiesPreviewProvider : PreviewParameterProvider<EarnOpportunitiesUM> {
override val values: Sequence<EarnOpportunitiesUM>
get() = sequenceOf(
ForYouEarnOpportunitiesPreviewData.tokensRewards,
ForYouEarnOpportunitiesPreviewData.noAvailableTokens,
ForYouEarnOpportunitiesPreviewData.allTokensActive,
ForYouEarnOpportunitiesPreviewData.loading,
)
}
// endregion

View file

@ -0,0 +1,99 @@
package com.tangem.features.foryou.impl.ui.preview
import com.tangem.core.ui.R
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds.row.token.TangemTokenRowUM
import com.tangem.core.ui.extensions.stringReference
import com.tangem.features.foryou.impl.entity.EarnOpportunitiesUM
import com.tangem.features.foryou.impl.entity.ForYouTokenListItemUM
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
internal object ForYouEarnOpportunitiesPreviewData {
private const val TOTAL_POTENTIAL_REWARD = "\$32.14/year"
private const val TOP_EARN_APY = "4.5%/year"
/** Tokens with earn opportunities: subtitle with the inline reward badge + flat token rows. */
val tokensRewards = EarnOpportunitiesUM.Content(
subtitleRes = R.string.for_you_earn_opportunities_tokens_rewards,
potentialReward = stringReference(TOTAL_POTENTIAL_REWARD),
potentialRewardType = null,
tokenList = persistentListOf(
earnTokenRow(
id = "token_0",
name = "Ethereum",
network = "Ethereum network",
topEnd = "+ \$24.16/year",
bottomEnd = "4,5%",
),
earnTokenRow(
id = "token_1",
name = "Solana",
network = "Solana network",
topEnd = "+ \$7.98/year",
bottomEnd = "6,2%",
),
),
)
/** No earnable tokens in the portfolio: top earn tokens teaser. */
val noAvailableTokens = EarnOpportunitiesUM.Content(
subtitleRes = R.string.for_you_earn_opportunities_no_available_tokens,
potentialReward = stringReference(TOP_EARN_APY),
potentialRewardType = stringReference("APY"),
tokenList = persistentListOf(
earnTokenRow(
id = "top_token_0",
name = "TON",
network = "TON network",
topEnd = "APY 4,5",
bottomEnd = "Staking",
),
),
)
/** Every earnable token is already earning: plain subtitle, no reward badge, no rows. */
val allTokensActive = EarnOpportunitiesUM.Content(
subtitleRes = R.string.for_you_earn_opportunities_all_tokens_active,
potentialReward = null,
potentialRewardType = null,
tokenList = persistentListOf(),
)
val loading = EarnOpportunitiesUM.Loading(
tokenList = List(size = 4) { index ->
ForYouTokenListItemUM(
tokenRowUM = TangemTokenRowUM.Loading(id = index.toString()),
tokenList = persistentListOf(),
isExpanded = false,
isExpandable = false,
)
}.toPersistentList(),
)
private fun earnTokenRow(
id: String,
name: String,
network: String,
topEnd: String,
bottomEnd: String,
): ForYouTokenListItemUM {
return ForYouTokenListItemUM(
tokenRowUM = TangemTokenRowUM.Content(
id = id,
headIconUM = TangemIconUM.Currency(CurrencyIconState.Loading),
titleUM = TangemTokenRowUM.TitleUM.Content(text = stringReference(name)),
subtitleUM = TangemTokenRowUM.SubtitleUM.Content(text = stringReference(network)),
topEndContentUM = TangemTokenRowUM.EndContentUM.Content(text = stringReference(topEnd)),
bottomEndContentUM = TangemTokenRowUM.EndContentUM.Content(text = stringReference(bottomEnd)),
onItemClick = null,
onItemLongClick = null,
),
tokenList = persistentListOf(),
isExpanded = false,
isExpandable = false,
)
}
}