diff --git a/core/res/src/main/res/values-fr/strings.xml b/core/res/src/main/res/values-fr/strings.xml
index 4871755d8e..d9df766b4e 100644
--- a/core/res/src/main/res/values-fr/strings.xml
+++ b/core/res/src/main/res/values-fr/strings.xml
@@ -106,7 +106,7 @@
Contact
Nom du contact
Copier l\'adresse
- Contact sauvegardé
+ Contact enregistré
Nous n\'avons pas pu créer le contact. Veuillez réessayer plus tard.
Supprimer le contact
Ce contact sera supprimé de tous vos carnets d\'adresses
@@ -128,6 +128,7 @@
Aucun contact pour le moment
Les contacts que vous ajouterez vont apparaître ici
Supprimer l\'adresse
+ Enregistrer l\'adresse
Enregistrer le contact
Enregistrer dans le portefeuille
Ce contact va être lié au carnet d\'adresses de ce portefeuille.
diff --git a/core/res/src/main/res/values-ja/strings.xml b/core/res/src/main/res/values-ja/strings.xml
index 34feb66471..ba11a5d289 100644
--- a/core/res/src/main/res/values-ja/strings.xml
+++ b/core/res/src/main/res/values-ja/strings.xml
@@ -104,6 +104,7 @@
- %d件のアドレス
アドレスを選択
+ すべてクリア
連絡先
連絡先名
アドレスをコピー
@@ -129,10 +130,12 @@
連絡先はまだありません
追加した連絡先はここに表示されます。
アドレスを削除
+ アドレスを保存
連絡先を保存
ウォレットに保存
この連絡先は、このウォレットのアドレス帳に紐付けられます。
結果が見つかりませんでした。\n別の名前を試してください。
+ すべて選択
ネットワークを選択
連絡先
保存されていない変更
diff --git a/core/res/src/main/res/values-pt-rBR/strings.xml b/core/res/src/main/res/values-pt-rBR/strings.xml
index 9d2f9d61d6..13735dcc37 100644
--- a/core/res/src/main/res/values-pt-rBR/strings.xml
+++ b/core/res/src/main/res/values-pt-rBR/strings.xml
@@ -109,7 +109,7 @@
Contato
Nome do contato
Copiar endereço
- Contato adicionado
+ Contato salvo
Não foi possível estabelecer contato. Tente novamente mais tarde.
Excluir contato
Este contato será excluído de toda a sua agenda de contatos.
@@ -725,6 +725,11 @@
Não foi possível enviar uma transação.
Erro na descrição da moeda
Analise seu portfólio e explore oportunidades de ganhos.
+ Oportunidades de ganhar
+ Todos os seus recursos estão em ação, explore novas oportunidades.
+ Levante-se %1$s anualmente
+ recompensas potenciais máximas %1$s
+ %1$s/ano
Avaliação de portfólio
Para você
Atualize agora
@@ -1919,6 +1924,10 @@
- %d cartão
- %d cartões
+ Isso ocorreu devido ao seu comportamento suspeito. Entre em contato com o suporte para saber mais.
+ Cashback desativado
+ Será depositado em %1$s
+ %1$s cashback em %2$s
Alterar código PIN
Volte ao aplicativo se você se esquecer.
Cartão
@@ -2046,6 +2055,7 @@
Configurar novo PIN
Definir PIN
Conta encerrada
+ Inativo
Substituindo seu cartão
Use o cartão ou anel para renovar a sessão
Use o cartão ou anel para renovar a sessão
@@ -2061,6 +2071,8 @@
Recarregue sua conta com qualquer token da carteira
Da sua Tangem Wallet
USDC na rede Polygon
+ Por favor, tente novamente ou entre em contato com o suporte se o problema persistir.
+ Não foi possível carregar os dados bancários.
Benefícios do visto
Os fundos de compras reembolsadas não serão devolvidos ao seu saldo na blockchain nem estarão disponíveis para saque, mas permanecerão no saldo do seu cartão para compras futuras
Observe
diff --git a/core/res/src/main/res/values-zh-rCN/strings.xml b/core/res/src/main/res/values-zh-rCN/strings.xml
index 4c000aebd7..36172e947b 100644
--- a/core/res/src/main/res/values-zh-rCN/strings.xml
+++ b/core/res/src/main/res/values-zh-rCN/strings.xml
@@ -130,6 +130,7 @@
尚无联系人
添加的联系人将显示在此处
移除地址
+ 保存地址
保存联系人
保存到钱包
该联系人将与该钱包的通讯录关联。
diff --git a/features/for-you/impl/build.gradle.kts b/features/for-you/impl/build.gradle.kts
index 26cbf40712..fcdb2d8087 100644
--- a/features/for-you/impl/build.gradle.kts
+++ b/features/for-you/impl/build.gradle.kts
@@ -70,4 +70,5 @@ dependencies {
/** Tests */
testImplementation(projects.common.test)
testImplementation(projects.test.core)
+ testImplementation(projects.test.mock)
}
\ No newline at end of file
diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/entity/ForYouUM.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/entity/ForYouUM.kt
index d1832b2bcb..564a3fa05a 100644
--- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/entity/ForYouUM.kt
+++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/entity/ForYouUM.kt
@@ -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,
)
@@ -31,6 +34,23 @@ internal sealed interface PortfolioReviewUM {
) : PortfolioReviewUM
}
+@Immutable
+internal sealed interface EarnOpportunitiesUM {
+
+ val tokenList: ImmutableList
+
+ data class Loading(
+ override val tokenList: ImmutableList,
+ ) : EarnOpportunitiesUM
+
+ data class Content(
+ override val tokenList: ImmutableList,
+ @param:StringRes val subtitleRes: Int,
+ val potentialReward: TextReference?,
+ val potentialRewardType: TextReference?,
+ ) : EarnOpportunitiesUM
+}
+
@Immutable
internal data class ForYouTokenListItemUM(
val tokenRowUM: TangemTokenRowUM,
diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/ForYouModel.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/ForYouModel.kt
index 9116c76ea9..8164f5d156 100644
--- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/ForYouModel.kt
+++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/ForYouModel.kt
@@ -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(
notifications = persistentListOf(),
+ earnOpportunities = EarnOpportunitiesUM.Loading(
+ tokenList = buildList {
+ 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 {
diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/ForYouPortfolioFormatters.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/ForYouPortfolioFormatters.kt
deleted file mode 100644
index 5596541fff..0000000000
--- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/ForYouPortfolioFormatters.kt
+++ /dev/null
@@ -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,
-)
\ No newline at end of file
diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/ForYouTokenListConverter.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/ForYouTokenListConverter.kt
index dabad495fa..1b9f893650 100644
--- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/ForYouTokenListConverter.kt
+++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/ForYouTokenListConverter.kt
@@ -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(
diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/ForYouUtils.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/ForYouUtils.kt
new file mode 100644
index 0000000000..6b2425900a
--- /dev/null
+++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/ForYouUtils.kt
@@ -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 =
+ (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,
+ val accountPotentialReward: BigDecimal,
+)
\ No newline at end of file
diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesConverter.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesConverter.kt
new file mode 100644
index 0000000000..a95b2a2f69
--- /dev/null
+++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesConverter.kt
@@ -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,
+ private val expandClick: (assetId: String) -> Unit,
+ private val yieldSupplyAvailability: Map,
+ private val yieldStakingAvailability: Map,
+ private val topEarnTokens: EarnTopToken?,
+) : Converter {
+
+ 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,
+ stakingApyMap: Map,
+ ): 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,
+ ): 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?,
+ )
+}
\ No newline at end of file
diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesNoTokensConverter.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesNoTokensConverter.kt
new file mode 100644
index 0000000000..2f363e16d9
--- /dev/null
+++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesNoTokensConverter.kt
@@ -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, EarnOpportunitiesUM> {
+
+ override fun convert(value: List): 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),
+ )
+ }
+}
\ No newline at end of file
diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesPotentialRewardsConverter.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesPotentialRewardsConverter.kt
new file mode 100644
index 0000000000..47d65c42b3
--- /dev/null
+++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesPotentialRewardsConverter.kt
@@ -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,
+ private val expandClick: (assetId: String) -> Unit,
+) : Converter, EarnOpportunitiesUM> {
+
+ private val rowConverter = ForYouEarnOpportunitiesTokenRowConverter(appCurrency = appCurrency)
+
+ override fun convert(value: List): 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,
+ )
+ }
+}
\ No newline at end of file
diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTokenRowConverter.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTokenRowConverter.kt
new file mode 100644
index 0000000000..7e9b237516
--- /dev/null
+++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTokenRowConverter.kt
@@ -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, TangemTokenRowUM> {
+
+ private val iconConverter = CryptoCurrencyToIconStateConverter()
+
+ override fun convert(value: Pair): 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))
+ }
+}
\ No newline at end of file
diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTokensActiveConverter.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTokensActiveConverter.kt
new file mode 100644
index 0000000000..dbe05ab54a
--- /dev/null
+++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTokensActiveConverter.kt
@@ -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, EarnOpportunitiesUM> {
+
+ override fun convert(value: List): 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,
+ )
+ }
+}
\ No newline at end of file
diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTopTokenRowConverter.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTopTokenRowConverter.kt
new file mode 100644
index 0000000000..c8db01a057
--- /dev/null
+++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTopTokenRowConverter.kt
@@ -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 {
+
+ 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)
+ }
+}
\ No newline at end of file
diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/ForYouContent.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/ForYouContent.kt
index b4ae2bcbfd..7314fb2afc 100644
--- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/ForYouContent.kt
+++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/ForYouContent.kt
@@ -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
get() = sequenceOf(
ForYouUM(
notifications = persistentListOf(ForYouNotification.UsedOutdatedData),
+ earnOpportunities = ForYouEarnOpportunitiesPreviewData.tokensRewards,
portfolioReviewUM = ForYouPortfolioReviewPreviewData.reviewContent,
),
)
diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/ForYouEarnOpportunities.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/ForYouEarnOpportunities.kt
new file mode 100644
index 0000000000..cb513e9ac9
--- /dev/null
+++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/ForYouEarnOpportunities.kt
@@ -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 {
+ 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 {
+ override val values: Sequence
+ get() = sequenceOf(
+ ForYouEarnOpportunitiesPreviewData.tokensRewards,
+ ForYouEarnOpportunitiesPreviewData.noAvailableTokens,
+ ForYouEarnOpportunitiesPreviewData.allTokensActive,
+ ForYouEarnOpportunitiesPreviewData.loading,
+ )
+}
+// endregion
\ No newline at end of file
diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/preview/ForYouEarnOpportunitiesPreviewData.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/preview/ForYouEarnOpportunitiesPreviewData.kt
new file mode 100644
index 0000000000..400a57c04b
--- /dev/null
+++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/preview/ForYouEarnOpportunitiesPreviewData.kt
@@ -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,
+ )
+ }
+}
\ No newline at end of file
diff --git a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/ForYouPortfolioFormattersTest.kt b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/ForYouPortfolioFormattersTest.kt
index 5b6cce0aea..1766aa4a97 100644
--- a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/ForYouPortfolioFormattersTest.kt
+++ b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/ForYouPortfolioFormattersTest.kt
@@ -3,6 +3,7 @@ package com.tangem.features.foryou.impl.model.converter
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
+import com.tangem.domain.models.network.Network
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.Nested
@@ -54,6 +55,62 @@ internal class ForYouPortfolioFormattersTest {
)
}
+ @Nested
+ inner class ForYouEarnAssetKey {
+
+ @Test
+ fun `GIVEN currency with raw id WHEN forYouEarnAssetKey THEN key is raw id to network raw id`() {
+ // Arrange
+ val currency = createCurrency(rawCurrencyId = "usd-coin", currencyId = "token-usdc", networkRawId = "ETH")
+
+ // Act
+ val result = currency.forYouEarnAssetKey()
+
+ // Assert
+ assertThat(result).isEqualTo("usd-coin" to "ETH")
+ }
+
+ @Test
+ fun `GIVEN custom token with no raw id WHEN forYouEarnAssetKey THEN falls back to currency id value`() {
+ // Arrange
+ val currency = createCurrency(rawCurrencyId = null, currencyId = "custom-token-id", networkRawId = "ETH")
+
+ // Act
+ val result = currency.forYouEarnAssetKey()
+
+ // Assert
+ assertThat(result).isEqualTo("custom-token-id" to "ETH")
+ }
+
+ @Test
+ fun `GIVEN same asset on different networks WHEN forYouEarnAssetKey THEN keys differ`() {
+ // Arrange
+ val onEthereum = createCurrency(rawCurrencyId = "usd-coin", currencyId = "usdc-eth", networkRawId = "ETH")
+ val onSolana = createCurrency(rawCurrencyId = "usd-coin", currencyId = "usdc-sol", networkRawId = "SOL")
+
+ // Act & Assert
+ assertThat(onEthereum.forYouEarnAssetKey()).isNotEqualTo(onSolana.forYouEarnAssetKey())
+ }
+
+ private fun createCurrency(
+ rawCurrencyId: String?,
+ currencyId: String,
+ networkRawId: String,
+ ): CryptoCurrency {
+ val id: CryptoCurrency.ID = mockk {
+ every { this@mockk.rawCurrencyId } returns rawCurrencyId?.let { CryptoCurrency.RawID(it) }
+ every { value } returns currencyId
+ }
+ val network: Network = mockk {
+ every { rawId } returns networkRawId
+ }
+ return mockk {
+ every { this@mockk.id } returns id
+ every { this@mockk.network } returns network
+ }
+ }
+ }
+
@Nested
inner class ToForYouPercent {
diff --git a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/ForYouTokenListConverterTest.kt b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/ForYouTokenListConverterTest.kt
index 97e1d86e69..192e969293 100644
--- a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/ForYouTokenListConverterTest.kt
+++ b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/ForYouTokenListConverterTest.kt
@@ -5,6 +5,7 @@ import com.tangem.core.ui.ds.row.token.TangemTokenRowUM
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.wrappedList
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
@@ -149,7 +150,9 @@ internal class ForYouTokenListConverterTest {
val otherRow = result.last().tokenRowUM as TangemTokenRowUM.Content
assertThat(otherRow.id).isEqualTo("for_you_other_assets")
val subtitle = otherRow.subtitleUM as TangemTokenRowUM.SubtitleUM.Content
- assertThat(subtitle.text).isEqualTo(pluralReference(R.plurals.market_chart_assets_android, count = 1))
+ assertThat(subtitle.text).isEqualTo(
+ pluralReference(R.plurals.market_chart_assets_android, count = 1, formatArgs = wrappedList(1)),
+ )
}
@Test
@@ -172,7 +175,9 @@ internal class ForYouTokenListConverterTest {
// Assert
val otherRow = result.last().tokenRowUM as TangemTokenRowUM.Content
val subtitle = otherRow.subtitleUM as TangemTokenRowUM.SubtitleUM.Content
- assertThat(subtitle.text).isEqualTo(pluralReference(R.plurals.market_chart_assets_android, count = 3))
+ assertThat(subtitle.text).isEqualTo(
+ pluralReference(R.plurals.market_chart_assets_android, count = 3, formatArgs = wrappedList(3)),
+ )
}
@Test
diff --git a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/EarnOpportunitiesTestFactory.kt b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/EarnOpportunitiesTestFactory.kt
new file mode 100644
index 0000000000..ec6c995036
--- /dev/null
+++ b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/EarnOpportunitiesTestFactory.kt
@@ -0,0 +1,194 @@
+package com.tangem.features.foryou.impl.model.converter.earnOpportunities
+
+import com.tangem.domain.account.models.AccountStatusList
+import com.tangem.domain.models.StatusSource
+import com.tangem.domain.models.account.Account
+import com.tangem.domain.models.account.AccountStatus
+import com.tangem.domain.models.currency.CryptoCurrency
+import com.tangem.domain.models.currency.CryptoCurrencyStatus
+import com.tangem.domain.models.earn.EarnRewardType
+import com.tangem.domain.models.earn.EarnToken
+import com.tangem.domain.models.earn.EarnTokenWithCurrency
+import com.tangem.domain.models.earn.EarnType
+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.features.foryou.impl.model.converter.EarnApyInfo
+import com.tangem.features.foryou.impl.model.converter.EarnOpportunities
+import com.tangem.test.mock.MockAccounts
+import io.mockk.every
+import io.mockk.mockk
+import java.math.BigDecimal
+
+/**
+ * Factories for the earn-opportunities converter tests. Every argument is defaulted so a test
+ * overrides only the fields it asserts on.
+ */
+
+internal fun createEarnCurrency(
+ tokenId: String? = "ethereum",
+ currencyId: String = "coin-ethereum",
+ name: String = "Ethereum",
+ networkRawId: String = "ETH",
+ networkName: String = "Ethereum",
+): CryptoCurrency {
+ val networkId: Network.ID = mockk {
+ every { rawId } returns Network.RawID(networkRawId)
+ }
+ val network: Network = mockk {
+ every { this@mockk.name } returns networkName
+ every { isTestnet } returns false
+ every { rawId } returns networkRawId
+ every { this@mockk.id } returns networkId
+ }
+ val id: CryptoCurrency.ID = mockk {
+ every { rawCurrencyId } returns tokenId?.let { CryptoCurrency.RawID(it) }
+ every { value } returns currencyId
+ }
+ return mockk {
+ every { this@mockk.id } returns id
+ every { this@mockk.name } returns name
+ every { this@mockk.network } returns network
+ every { isCustom } returns false
+ every { iconUrl } returns null
+ }
+}
+
+/** A token currency whose `yieldSupplyKey()` is `"_"`. */
+internal fun createEarnTokenCurrency(
+ contractAddress: String = "0xabc",
+ tokenId: String? = "usd-coin",
+ currencyId: String = "token-usdc",
+ name: String = "USD Coin",
+ networkRawId: String = "ETH",
+ networkName: String = "Ethereum",
+): CryptoCurrency.Token {
+ val networkId: Network.ID = mockk {
+ every { rawId } returns Network.RawID(networkRawId)
+ }
+ val network: Network = mockk {
+ every { this@mockk.name } returns networkName
+ every { isTestnet } returns false
+ every { rawId } returns networkRawId
+ every { this@mockk.id } returns networkId
+ }
+ val id: CryptoCurrency.ID = mockk {
+ every { rawCurrencyId } returns tokenId?.let { CryptoCurrency.RawID(it) }
+ every { value } returns currencyId
+ }
+ return mockk {
+ every { this@mockk.id } returns id
+ every { this@mockk.name } returns name
+ every { this@mockk.network } returns network
+ every { this@mockk.contractAddress } returns contractAddress
+ every { isCustom } returns false
+ every { iconUrl } returns null
+ }
+}
+
+internal fun createEarnToken(
+ apy: String = "5.5",
+ networkId: String = "ethereum",
+ rewardType: EarnRewardType = EarnRewardType.APY,
+ type: EarnType = EarnType.STAKING,
+ tokenId: String = "ethereum",
+ tokenSymbol: String = "ETH",
+ tokenName: String = "Ethereum",
+ tokenAddress: String? = null,
+ decimalCount: Int? = null,
+): EarnToken = EarnToken(
+ apy = apy,
+ networkId = networkId,
+ rewardType = rewardType,
+ type = type,
+ tokenId = tokenId,
+ tokenSymbol = tokenSymbol,
+ tokenName = tokenName,
+ tokenAddress = tokenAddress,
+ decimalCount = decimalCount,
+)
+
+/** A top-earn suggestion whose row id becomes `"-"`. */
+internal fun createTopEarnToken(
+ tokenId: String = "ethereum",
+ networkRawId: String = "ETH",
+ networkName: String = "Ethereum",
+ name: String = "Ethereum",
+ apy: String = "5.5",
+ type: EarnType = EarnType.STAKING,
+ rewardType: EarnRewardType = EarnRewardType.APY,
+): EarnTokenWithCurrency = EarnTokenWithCurrency(
+ networkName = networkName,
+ earnToken = createEarnToken(apy = apy, tokenId = tokenId, type = type, rewardType = rewardType),
+ cryptoCurrency = createEarnCurrency(
+ tokenId = tokenId,
+ currencyId = "$tokenId-$networkRawId",
+ name = name,
+ networkRawId = networkRawId,
+ networkName = networkName,
+ ),
+)
+
+/** A fully resolved status value suitable for rendering rows (fiat amount, sources, no error). */
+internal fun createRowLoadedValue(
+ fiatAmount: BigDecimal = BigDecimal("100"),
+ source: StatusSource = StatusSource.ACTUAL,
+): CryptoCurrencyStatus.Loaded = mockk {
+ every { this@mockk.fiatAmount } returns fiatAmount
+ every { isError } returns false
+ every { sources } returns CryptoCurrencyStatus.Sources(
+ networkSource = source,
+ quoteSource = source,
+ stakingBalanceSource = source,
+ )
+}
+
+/** A status value carrying the earn-related fields read by `ForYouEarnOpportunitiesConverter`. */
+internal fun createEarnStatusValue(
+ fiatAmount: BigDecimal = BigDecimal("100"),
+ yieldSupplyActive: Boolean? = null,
+ isStakingActive: Boolean = false,
+): CryptoCurrencyStatus.Loaded = mockk {
+ every { this@mockk.fiatAmount } returns fiatAmount
+ every { yieldSupplyStatus } returns yieldSupplyActive?.let { active ->
+ mockk { every { isActive } returns active }
+ }
+ every { stakingBalance } returns if (isStakingActive) mockk() else null
+ every { isError } returns false
+ every { sources } returns CryptoCurrencyStatus.Sources()
+}
+
+internal fun createStatus(
+ currency: CryptoCurrency,
+ value: CryptoCurrencyStatus.Value = CryptoCurrencyStatus.Loading,
+): CryptoCurrencyStatus = CryptoCurrencyStatus(currency = currency, value = value)
+
+internal fun createEarnOpportunities(
+ account: Account.CryptoPortfolio = MockAccounts.createAccount(derivationIndex = 1),
+ earnCurrencues: Map = mapOf(
+ createStatus(createEarnCurrency()) to createEarnApyInfo(),
+ ),
+ accountPotentialReward: BigDecimal = BigDecimal.ZERO,
+): EarnOpportunities = EarnOpportunities(
+ account = account,
+ earnCurrencues = earnCurrencues,
+ accountPotentialReward = accountPotentialReward,
+)
+
+internal fun createEarnApyInfo(
+ isActive: Boolean = true,
+ apy: BigDecimal? = BigDecimal("0.05"),
+ potentialRewards: BigDecimal? = null,
+): EarnApyInfo = EarnApyInfo(isActive = isActive, apy = apy, potentialRewards = potentialRewards)
+
+internal fun createPortfolioStatus(
+ currencies: List,
+ account: Account.CryptoPortfolio = MockAccounts.createAccount(derivationIndex = 1),
+): AccountStatus.CryptoPortfolio = mockk {
+ every { flattenCurrencies() } returns currencies
+ every { this@mockk.account } returns account
+}
+
+internal fun createAccountStatusList(vararg statuses: AccountStatus): AccountStatusList = mockk {
+ every { accountStatuses } returns statuses.toList()
+}
\ No newline at end of file
diff --git a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesConverterTest.kt b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesConverterTest.kt
new file mode 100644
index 0000000000..e38e2b23dc
--- /dev/null
+++ b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesConverterTest.kt
@@ -0,0 +1,245 @@
+package com.tangem.features.foryou.impl.model.converter.earnOpportunities
+
+import com.google.common.truth.Truth.assertThat
+import com.tangem.common.ui.R
+import com.tangem.core.ui.extensions.resourceReference
+import com.tangem.core.ui.extensions.wrappedList
+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.currency.CryptoCurrency
+import com.tangem.domain.models.currency.yieldSupplyKey
+import com.tangem.domain.models.earn.EarnTopToken
+import com.tangem.domain.staking.model.StakingAvailability
+import com.tangem.domain.staking.model.StakingOption
+import com.tangem.features.foryou.impl.entity.EarnOpportunitiesUM
+import io.mockk.every
+import io.mockk.mockk
+import org.junit.jupiter.api.Nested
+import org.junit.jupiter.api.Test
+import java.math.BigDecimal
+import java.math.RoundingMode
+
+internal class ForYouEarnOpportunitiesConverterTest {
+
+ private val appCurrency: AppCurrency = AppCurrency.Default
+
+ @Nested
+ inner class StateSelection {
+
+ @Test
+ fun `GIVEN null account status list WHEN convert THEN no-tokens state`() {
+ // Arrange
+ val converter = createConverter()
+
+ // Act
+ val result = converter.convert(null) as EarnOpportunitiesUM.Content
+
+ // Assert
+ assertThat(result.subtitleRes).isEqualTo(R.string.for_you_earn_opportunities_no_available_tokens)
+ }
+
+ @Test
+ fun `GIVEN currencies without any earn option WHEN convert THEN no-tokens state`() {
+ // Arrange — no yield map entries and no staking availability → nothing is earn-eligible
+ val status = createStatus(createEarnCurrency(), createEarnStatusValue())
+ val converter = createConverter()
+
+ // Act
+ val result = converter.convert(createAccountStatusList(createPortfolioStatus(listOf(status))))
+
+ // Assert
+ assertThat((result as EarnOpportunitiesUM.Content).subtitleRes)
+ .isEqualTo(R.string.for_you_earn_opportunities_no_available_tokens)
+ }
+
+ @Test
+ fun `GIVEN every earn-eligible token already staked WHEN convert THEN all-active state`() {
+ // Arrange
+ val currency = createEarnCurrency()
+ val status = createStatus(currency, createEarnStatusValue(isStakingActive = true))
+ val converter = createConverter(
+ yieldStakingAvailability = mapOf(currency to stakingAvailable(apy = BigDecimal("0.05"))),
+ )
+
+ // Act
+ val result = converter.convert(createAccountStatusList(createPortfolioStatus(listOf(status))))
+
+ // Assert
+ assertThat((result as EarnOpportunitiesUM.Content).subtitleRes)
+ .isEqualTo(R.string.for_you_earn_opportunities_all_tokens_active)
+ }
+
+ @Test
+ fun `GIVEN earn-eligible token not yet earning WHEN convert THEN potential-rewards state`() {
+ // Arrange
+ val currency = createEarnCurrency()
+ val status = createStatus(currency, createEarnStatusValue(fiatAmount = BigDecimal("100")))
+ val converter = createConverter(
+ yieldStakingAvailability = mapOf(currency to stakingAvailable(apy = BigDecimal("0.05"))),
+ )
+
+ // Act
+ val result = converter.convert(createAccountStatusList(createPortfolioStatus(listOf(status))))
+
+ // Assert
+ assertThat((result as EarnOpportunitiesUM.Content).subtitleRes)
+ .isEqualTo(R.string.for_you_earn_opportunities_tokens_rewards)
+ }
+ }
+
+ @Nested
+ inner class EarnEligibility {
+
+ @Test
+ fun `GIVEN zero balance and inactive earn WHEN convert THEN token is not eligible`() {
+ // Arrange — nothing to earn on: no balance and not already earning
+ val currency = createEarnCurrency()
+ val status = createStatus(currency, createEarnStatusValue(fiatAmount = BigDecimal.ZERO))
+ val converter = createConverter(
+ yieldStakingAvailability = mapOf(currency to stakingAvailable(apy = BigDecimal("0.05"))),
+ )
+
+ // Act
+ val result = converter.convert(createAccountStatusList(createPortfolioStatus(listOf(status))))
+
+ // Assert
+ assertThat((result as EarnOpportunitiesUM.Content).subtitleRes)
+ .isEqualTo(R.string.for_you_earn_opportunities_no_available_tokens)
+ }
+
+ @Test
+ fun `GIVEN zero balance but active stake WHEN convert THEN token stays visible as active`() {
+ // Arrange
+ val currency = createEarnCurrency()
+ val status = createStatus(
+ currency,
+ createEarnStatusValue(fiatAmount = BigDecimal.ZERO, isStakingActive = true),
+ )
+ val converter = createConverter(
+ yieldStakingAvailability = mapOf(currency to stakingAvailable(apy = BigDecimal("0.05"))),
+ )
+
+ // Act
+ val result = converter.convert(createAccountStatusList(createPortfolioStatus(listOf(status))))
+
+ // Assert
+ assertThat((result as EarnOpportunitiesUM.Content).subtitleRes)
+ .isEqualTo(R.string.for_you_earn_opportunities_all_tokens_active)
+ }
+
+ @Test
+ fun `GIVEN full staking pool without existing stake WHEN convert THEN token is not eligible`() {
+ // Arrange — Full = no free capacity: new stakes are not offered
+ val currency = createEarnCurrency()
+ val status = createStatus(currency, createEarnStatusValue(fiatAmount = BigDecimal("100")))
+ val converter = createConverter(
+ yieldStakingAvailability = mapOf(
+ currency to StakingAvailability.Full(option = stakingOption(apy = BigDecimal("0.05"))),
+ ),
+ )
+
+ // Act
+ val result = converter.convert(createAccountStatusList(createPortfolioStatus(listOf(status))))
+
+ // Assert
+ assertThat((result as EarnOpportunitiesUM.Content).subtitleRes)
+ .isEqualTo(R.string.for_you_earn_opportunities_no_available_tokens)
+ }
+
+ @Test
+ fun `GIVEN full staking pool with existing stake WHEN convert THEN token stays visible as active`() {
+ // Arrange
+ val currency = createEarnCurrency()
+ val status = createStatus(
+ currency,
+ createEarnStatusValue(fiatAmount = BigDecimal("100"), isStakingActive = true),
+ )
+ val converter = createConverter(
+ yieldStakingAvailability = mapOf(
+ currency to StakingAvailability.Full(option = stakingOption(apy = BigDecimal("0.05"))),
+ ),
+ )
+
+ // Act
+ val result = converter.convert(createAccountStatusList(createPortfolioStatus(listOf(status))))
+
+ // Assert
+ assertThat((result as EarnOpportunitiesUM.Content).subtitleRes)
+ .isEqualTo(R.string.for_you_earn_opportunities_all_tokens_active)
+ }
+ }
+
+ @Nested
+ inner class ApyResolution {
+
+ @Test
+ fun `GIVEN token eligible for both yield and staking WHEN convert THEN yield rate wins`() {
+ // Arrange — yield 10.00% vs staking 50%: the reward must be computed from the yield rate
+ val token = createEarnTokenCurrency()
+ val status = createStatus(token, createEarnStatusValue(fiatAmount = BigDecimal("100")))
+ val converter = createConverter(
+ yieldSupplyAvailability = mapOf(token.yieldSupplyKey() to BigDecimal("10.00")),
+ yieldStakingAvailability = mapOf(
+ token to stakingAvailable(apy = BigDecimal("0.50")),
+ ),
+ )
+
+ // Act
+ val result = converter.convert(createAccountStatusList(createPortfolioStatus(listOf(status))))
+
+ // Assert — 100 * (10.00 / 100) = 10.00 per year, not 50
+ assertThat((result as EarnOpportunitiesUM.Content).potentialReward)
+ .isEqualTo(expectedPerYearReward(fiat = BigDecimal("100"), yieldPercent = BigDecimal("10.00")))
+ }
+
+ @Test
+ fun `GIVEN staking-only token WHEN convert THEN staking rate is used for the reward`() {
+ // Arrange
+ val currency = createEarnCurrency()
+ val status = createStatus(currency, createEarnStatusValue(fiatAmount = BigDecimal("200")))
+ val converter = createConverter(
+ yieldStakingAvailability = mapOf(currency to stakingAvailable(apy = BigDecimal("0.04"))),
+ )
+
+ // Act
+ val result = converter.convert(createAccountStatusList(createPortfolioStatus(listOf(status))))
+
+ // Assert — 200 * 0.04 = 8 per year
+ val expectedTotal = BigDecimal("200").multiply(BigDecimal("0.04"))
+ assertThat((result as EarnOpportunitiesUM.Content).potentialReward)
+ .isEqualTo(expectedTotal.expectedPerYearText())
+ }
+ }
+
+ private fun createConverter(
+ yieldSupplyAvailability: Map = emptyMap(),
+ yieldStakingAvailability: Map = emptyMap(),
+ topEarnTokens: EarnTopToken? = null,
+ isAccountsModeEnabled: Boolean = false,
+ ) = ForYouEarnOpportunitiesConverter(
+ appCurrency = appCurrency,
+ isAccountsModeEnabled = isAccountsModeEnabled,
+ expandedAssetIds = emptySet(),
+ expandClick = {},
+ yieldSupplyAvailability = yieldSupplyAvailability,
+ yieldStakingAvailability = yieldStakingAvailability,
+ topEarnTokens = topEarnTokens,
+ )
+
+ private fun stakingOption(apy: BigDecimal): StakingOption.P2PEthPool = mockk {
+ every { this@mockk.apy } returns apy
+ }
+
+ private fun stakingAvailable(apy: BigDecimal): StakingAvailability =
+ StakingAvailability.Available(option = stakingOption(apy))
+
+ /** Mirrors the production reward computation: `fiat * (yieldPercent / 100)`, rendered per year. */
+ private fun expectedPerYearReward(fiat: BigDecimal, yieldPercent: BigDecimal) =
+ fiat.multiply(yieldPercent.divide(BigDecimal("100"), RoundingMode.HALF_UP)).expectedPerYearText()
+
+ private fun BigDecimal.expectedPerYearText() = resourceReference(
+ R.string.for_you_earn_per_year,
+ wrappedList(format { fiat(fiatCurrencySymbol = appCurrency.symbol, fiatCurrencyCode = appCurrency.code) }),
+ )
+}
\ No newline at end of file
diff --git a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesNoTokensConverterTest.kt b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesNoTokensConverterTest.kt
new file mode 100644
index 0000000000..614e819c4b
--- /dev/null
+++ b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesNoTokensConverterTest.kt
@@ -0,0 +1,66 @@
+package com.tangem.features.foryou.impl.model.converter.earnOpportunities
+
+import arrow.core.right
+import com.google.common.truth.Truth.assertThat
+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.EarnRewardType
+import com.tangem.features.foryou.impl.entity.EarnOpportunitiesUM
+import org.junit.jupiter.api.Test
+
+internal class ForYouEarnOpportunitiesNoTokensConverterTest {
+
+ @Test
+ fun `GIVEN more top tokens than the cap WHEN convert THEN only first five are suggested`() {
+ // Arrange
+ val converter = ForYouEarnOpportunitiesNoTokensConverter(
+ topEarnTokens = List(7) { index ->
+ createTopEarnToken(tokenId = "token-$index", networkRawId = "NET")
+ }.right(),
+ )
+
+ // Act
+ val result = converter.convert(emptyList()) as EarnOpportunitiesUM.Content
+
+ // Assert
+ assertThat(result.tokenList.map { it.tokenRowUM.id })
+ .containsExactly("token-0-NET", "token-1-NET", "token-2-NET", "token-3-NET", "token-4-NET")
+ .inOrder()
+ }
+
+ @Test
+ fun `GIVEN top tokens WHEN convert THEN header shows first suggestion's rate and reward type`() {
+ // Arrange
+ val converter = ForYouEarnOpportunitiesNoTokensConverter(
+ topEarnTokens = listOf(
+ createTopEarnToken(apy = "7.25", rewardType = EarnRewardType.APR),
+ createTopEarnToken(tokenId = "solana", apy = "99.9", rewardType = EarnRewardType.APY),
+ ).right(),
+ )
+
+ // Act
+ val result = converter.convert(emptyList()) as EarnOpportunitiesUM.Content
+
+ // Assert — mirrors the production rate rendering for the first (best) suggestion
+ val expectedRate = "7.25".parseBigDecimalOrNull().format { percent() }
+ assertThat(result.potentialReward).isEqualTo(stringReference(expectedRate))
+ assertThat(result.potentialRewardType).isEqualTo(stringReference("APR"))
+ assertThat(result.subtitleRes).isEqualTo(R.string.for_you_earn_opportunities_no_available_tokens)
+ }
+
+ @Test
+ fun `GIVEN no top tokens loaded WHEN convert THEN suggestions are empty and reward type is absent`() {
+ // Arrange
+ val converter = ForYouEarnOpportunitiesNoTokensConverter(topEarnTokens = null)
+
+ // Act
+ val result = converter.convert(emptyList()) as EarnOpportunitiesUM.Content
+
+ // Assert
+ assertThat(result.tokenList).isEmpty()
+ assertThat(result.potentialRewardType).isNull()
+ }
+}
\ No newline at end of file
diff --git a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesPotentialRewardsConverterTest.kt b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesPotentialRewardsConverterTest.kt
new file mode 100644
index 0000000000..98cc7f3b16
--- /dev/null
+++ b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesPotentialRewardsConverterTest.kt
@@ -0,0 +1,146 @@
+package com.tangem.features.foryou.impl.model.converter.earnOpportunities
+
+import com.google.common.truth.Truth.assertThat
+import com.tangem.common.ui.R
+import com.tangem.core.ui.ds.row.token.TangemTokenRowUM
+import com.tangem.core.ui.extensions.resourceReference
+import com.tangem.core.ui.extensions.wrappedList
+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.features.foryou.impl.entity.EarnOpportunitiesUM
+import com.tangem.test.mock.MockAccounts
+import org.junit.jupiter.api.Test
+import java.math.BigDecimal
+
+internal class ForYouEarnOpportunitiesPotentialRewardsConverterTest {
+
+ private val appCurrency: AppCurrency = AppCurrency.Default
+
+ @Test
+ fun `GIVEN accounts mode off WHEN convert THEN one flat row per earn currency`() {
+ // Arrange
+ val earnData = createEarnOpportunities(
+ earnCurrencues = listOf("token-a", "token-b").associate { currencyId ->
+ createStatus(
+ createEarnCurrency(tokenId = currencyId, currencyId = currencyId),
+ createRowLoadedValue(),
+ ) to createEarnApyInfo(isActive = false)
+ },
+ )
+ val converter = createConverter(isAccountsModeEnabled = false)
+
+ // Act
+ val result = converter.convert(listOf(earnData)) as EarnOpportunitiesUM.Content
+
+ // Assert — flat, non-expandable token rows
+ assertThat(result.tokenList.map { it.tokenRowUM.id }).containsExactly("token-a", "token-b").inOrder()
+ assertThat(result.tokenList.map { it.isExpandable }).containsExactly(false, false)
+ assertThat(result.tokenList.flatMap { it.tokenList }).isEmpty()
+ }
+
+ @Test
+ fun `GIVEN accounts mode on WHEN convert THEN one expandable account row with token children`() {
+ // Arrange
+ val account = MockAccounts.createAccount(derivationIndex = 1, name = "Earn account")
+ val earnData = createEarnOpportunities(
+ account = account,
+ earnCurrencues = listOf("token-a", "token-b").associate { currencyId ->
+ createStatus(
+ createEarnCurrency(tokenId = currencyId, currencyId = currencyId),
+ createRowLoadedValue(),
+ ) to createEarnApyInfo(isActive = false)
+ },
+ )
+ val converter = createConverter(isAccountsModeEnabled = true)
+
+ // Act
+ val result = converter.convert(listOf(earnData)) as EarnOpportunitiesUM.Content
+
+ // Assert — a single account row hosting both token rows as children
+ val item = result.tokenList.single()
+ assertThat(item.tokenRowUM.id).isEqualTo(account.accountId.value)
+ assertThat(item.isExpandable).isTrue()
+ assertThat(item.isExpanded).isFalse()
+ assertThat(item.tokenList.map { it.id }).containsExactly("token-a", "token-b")
+ }
+
+ @Test
+ fun `GIVEN account id in expanded set WHEN convert THEN account row is expanded`() {
+ // Arrange
+ val account = MockAccounts.createAccount(derivationIndex = 1)
+ val earnData = createEarnOpportunities(
+ account = account,
+ earnCurrencues = mapOf(
+ createStatus(createEarnCurrency(), createRowLoadedValue()) to createEarnApyInfo(isActive = false),
+ ),
+ )
+ val converter = createConverter(
+ isAccountsModeEnabled = true,
+ expandedAssetIds = setOf(account.accountId.value),
+ )
+
+ // Act
+ val result = converter.convert(listOf(earnData)) as EarnOpportunitiesUM.Content
+
+ // Assert
+ assertThat(result.tokenList.single().isExpanded).isTrue()
+ }
+
+ @Test
+ fun `GIVEN account row clicked WHEN convert THEN expand callback receives account id`() {
+ // Arrange
+ val account = MockAccounts.createAccount(derivationIndex = 1)
+ val earnData = createEarnOpportunities(
+ account = account,
+ earnCurrencues = mapOf(
+ createStatus(createEarnCurrency(), createRowLoadedValue()) to createEarnApyInfo(isActive = false),
+ ),
+ )
+ var clickedAssetId: String? = null
+ val converter = createConverter(isAccountsModeEnabled = true, expandClick = { clickedAssetId = it })
+
+ // Act
+ val result = converter.convert(listOf(earnData)) as EarnOpportunitiesUM.Content
+ (result.tokenList.single().tokenRowUM as TangemTokenRowUM.Content).onItemClick?.invoke()
+
+ // Assert
+ assertThat(clickedAssetId).isEqualTo(account.accountId.value)
+ }
+
+ @Test
+ fun `GIVEN several accounts WHEN convert THEN header reward is the sum across accounts`() {
+ // Arrange
+ val first = createEarnOpportunities(
+ account = MockAccounts.createAccount(derivationIndex = 1),
+ accountPotentialReward = BigDecimal("10"),
+ )
+ val second = createEarnOpportunities(
+ account = MockAccounts.createAccount(derivationIndex = 2),
+ accountPotentialReward = BigDecimal("2.5"),
+ )
+ val converter = createConverter(isAccountsModeEnabled = false)
+
+ // Act
+ val result = converter.convert(listOf(first, second)) as EarnOpportunitiesUM.Content
+
+ // Assert — mirrors the production per-year fiat rendering of the 12.5 total
+ val expectedTotal = BigDecimal("12.5").format {
+ fiat(fiatCurrencySymbol = appCurrency.symbol, fiatCurrencyCode = appCurrency.code)
+ }
+ assertThat(result.potentialReward)
+ .isEqualTo(resourceReference(R.string.for_you_earn_per_year, wrappedList(expectedTotal)))
+ assertThat(result.subtitleRes).isEqualTo(R.string.for_you_earn_opportunities_tokens_rewards)
+ }
+
+ private fun createConverter(
+ isAccountsModeEnabled: Boolean,
+ expandedAssetIds: Set = emptySet(),
+ expandClick: (String) -> Unit = {},
+ ) = ForYouEarnOpportunitiesPotentialRewardsConverter(
+ appCurrency = appCurrency,
+ isAccountsModeEnabled = isAccountsModeEnabled,
+ expandedAssetIds = expandedAssetIds,
+ expandClick = expandClick,
+ )
+}
\ No newline at end of file
diff --git a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTokenRowConverterTest.kt b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTokenRowConverterTest.kt
new file mode 100644
index 0000000000..2c7840246c
--- /dev/null
+++ b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTokenRowConverterTest.kt
@@ -0,0 +1,116 @@
+package com.tangem.features.foryou.impl.model.converter.earnOpportunities
+
+import com.google.common.truth.Truth.assertThat
+import com.tangem.core.ui.R
+import com.tangem.core.ui.ds.row.token.TangemTokenRowUM
+import com.tangem.core.ui.extensions.TextReference
+import com.tangem.core.ui.extensions.combinedReference
+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.fiat
+import com.tangem.core.ui.format.bigdecimal.format
+import com.tangem.core.ui.format.bigdecimal.percent
+import com.tangem.domain.appcurrency.model.AppCurrency
+import com.tangem.domain.models.StatusSource
+import com.tangem.domain.models.currency.CryptoCurrencyStatus
+import com.tangem.utils.StringsSigns
+import io.mockk.every
+import io.mockk.mockk
+import org.junit.jupiter.api.Test
+import java.math.BigDecimal
+
+internal class ForYouEarnOpportunitiesTokenRowConverterTest {
+
+ private val appCurrency: AppCurrency = AppCurrency.Default
+ private val converter = ForYouEarnOpportunitiesTokenRowConverter(appCurrency = appCurrency)
+
+ @Test
+ fun `GIVEN loading status WHEN convert THEN row is Loading with currency id`() {
+ // Arrange
+ val status = createStatus(createEarnCurrency(currencyId = "coin-eth"), CryptoCurrencyStatus.Loading)
+
+ // Act
+ val result = converter.convert(status to createEarnApyInfo())
+
+ // Assert
+ assertThat(result).isEqualTo(TangemTokenRowUM.Loading(id = "coin-eth"))
+ }
+
+ @Test
+ fun `GIVEN loaded status WHEN convert THEN top end is the yearly earn from balance and rate`() {
+ // Arrange — 200 fiat at 5% → +10.00/year
+ val status = createStatus(
+ createEarnCurrency(currencyId = "coin-eth"),
+ createRowLoadedValue(fiatAmount = BigDecimal("200")),
+ )
+
+ // Act
+ val result = converter.convert(status to createEarnApyInfo(apy = BigDecimal("0.05")))
+ as TangemTokenRowUM.Content
+
+ // Assert — mirrors the production "+/year" rendering
+ val expectedEarn = BigDecimal("200").multiply(BigDecimal("0.05")).format {
+ fiat(fiatCurrencySymbol = appCurrency.symbol, fiatCurrencyCode = appCurrency.code)
+ }
+ val topEnd = result.topEndContentUM as TangemTokenRowUM.EndContentUM.Content
+ assertThat(topEnd.text).isEqualTo(
+ combinedReference(
+ stringReference(StringsSigns.PLUS),
+ resourceReference(R.string.for_you_earn_per_year, wrappedList(expectedEarn)),
+ ),
+ )
+ }
+
+ @Test
+ fun `GIVEN loaded status WHEN convert THEN bottom end is the styled percent rate`() {
+ // Arrange
+ val status = createStatus(createEarnCurrency(), createRowLoadedValue())
+
+ // Act
+ val result = converter.convert(status to createEarnApyInfo(apy = BigDecimal("0.05")))
+ as TangemTokenRowUM.Content
+
+ // Assert
+ val bottomEnd = result.bottomEndContentUM as TangemTokenRowUM.EndContentUM.Content
+ val styled = bottomEnd.text as TextReference.StyledStr
+ assertThat(styled.value).isEqualTo(BigDecimal("0.05").format { percent() })
+ }
+
+ @Test
+ fun `GIVEN loaded status from stale cache WHEN convert THEN error-sync icon shown on both ends`() {
+ // Arrange
+ val status = createStatus(
+ createEarnCurrency(),
+ createRowLoadedValue(source = StatusSource.ONLY_CACHE),
+ )
+
+ // Act
+ val result = converter.convert(status to createEarnApyInfo()) as TangemTokenRowUM.Content
+
+ // Assert
+ val topEnd = result.topEndContentUM as TangemTokenRowUM.EndContentUM.Content
+ val bottomEnd = result.bottomEndContentUM as TangemTokenRowUM.EndContentUM.Content
+ assertThat(topEnd.startIcons).hasSize(1)
+ assertThat(bottomEnd.startIcons).hasSize(1)
+ }
+
+ @Test
+ fun `GIVEN unreachable status WHEN convert THEN both ends are dashes`() {
+ // Arrange
+ val unreachable: CryptoCurrencyStatus.Unreachable = mockk {
+ every { fiatAmount } returns null
+ every { isError } returns true
+ }
+ val status = createStatus(createEarnCurrency(), unreachable)
+
+ // Act
+ val result = converter.convert(status to createEarnApyInfo()) as TangemTokenRowUM.Content
+
+ // Assert
+ val topEnd = result.topEndContentUM as TangemTokenRowUM.EndContentUM.Content
+ val bottomEnd = result.bottomEndContentUM as TangemTokenRowUM.EndContentUM.Content
+ assertThat(topEnd.text).isEqualTo(stringReference(StringsSigns.DASH_SIGN))
+ assertThat(bottomEnd.text).isEqualTo(stringReference(StringsSigns.DASH_SIGN))
+ }
+}
\ No newline at end of file
diff --git a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTokensActiveConverterTest.kt b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTokensActiveConverterTest.kt
new file mode 100644
index 0000000000..6d5ff554ef
--- /dev/null
+++ b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTokensActiveConverterTest.kt
@@ -0,0 +1,112 @@
+package com.tangem.features.foryou.impl.model.converter.earnOpportunities
+
+import arrow.core.left
+import arrow.core.right
+import com.google.common.truth.Truth.assertThat
+import com.tangem.common.ui.R
+import com.tangem.domain.models.earn.EarnError
+import com.tangem.features.foryou.impl.entity.EarnOpportunitiesUM
+import kotlinx.collections.immutable.persistentListOf
+import org.junit.jupiter.api.Test
+
+internal class ForYouEarnOpportunitiesTokensActiveConverterTest {
+
+ @Test
+ fun `GIVEN top tokens contain active portfolio assets WHEN convert THEN active ones are excluded`() {
+ // Arrange
+ val activePortfolio = createEarnOpportunities(
+ earnCurrencues = mapOf(
+ createStatus(createEarnCurrency(tokenId = "ethereum", networkRawId = "ETH")) to createEarnApyInfo(),
+ ),
+ )
+ val converter = ForYouEarnOpportunitiesTokensActiveConverter(
+ topEarnTokens = listOf(
+ createTopEarnToken(tokenId = "ethereum", networkRawId = "ETH"),
+ createTopEarnToken(tokenId = "solana", networkRawId = "SOL"),
+ ).right(),
+ )
+
+ // Act
+ val result = converter.convert(listOf(activePortfolio)) as EarnOpportunitiesUM.Content
+
+ // Assert
+ assertThat(result.tokenList.map { it.tokenRowUM.id }).containsExactly("solana-SOL")
+ }
+
+ @Test
+ fun `GIVEN more suggestions than the cap WHEN convert THEN filtering happens before the top-5 cut`() {
+ // Arrange — two of the first candidates are active; the cap must still be filled from the tail
+ val activePortfolio = createEarnOpportunities(
+ earnCurrencues = listOf("token-0", "token-1").associate { tokenId ->
+ createStatus(createEarnCurrency(tokenId = tokenId, networkRawId = "NET")) to createEarnApyInfo()
+ },
+ )
+ val converter = ForYouEarnOpportunitiesTokensActiveConverter(
+ topEarnTokens = List(8) { index ->
+ createTopEarnToken(tokenId = "token-$index", networkRawId = "NET")
+ }.right(),
+ )
+
+ // Act
+ val result = converter.convert(listOf(activePortfolio)) as EarnOpportunitiesUM.Content
+
+ // Assert
+ assertThat(result.tokenList.map { it.tokenRowUM.id })
+ .containsExactly("token-2-NET", "token-3-NET", "token-4-NET", "token-5-NET", "token-6-NET")
+ .inOrder()
+ }
+
+ @Test
+ fun `GIVEN asset active on another network WHEN convert THEN suggestion on a new network is kept`() {
+ // Arrange — matching is per asset AND network, not per asset
+ val activePortfolio = createEarnOpportunities(
+ earnCurrencues = mapOf(
+ createStatus(createEarnCurrency(tokenId = "usd-coin", networkRawId = "ETH")) to createEarnApyInfo(),
+ ),
+ )
+ val converter = ForYouEarnOpportunitiesTokensActiveConverter(
+ topEarnTokens = listOf(
+ createTopEarnToken(tokenId = "usd-coin", networkRawId = "ETH"),
+ createTopEarnToken(tokenId = "usd-coin", networkRawId = "SOL"),
+ ).right(),
+ )
+
+ // Act
+ val result = converter.convert(listOf(activePortfolio)) as EarnOpportunitiesUM.Content
+
+ // Assert
+ assertThat(result.tokenList.map { it.tokenRowUM.id }).containsExactly("usd-coin-SOL")
+ }
+
+ @Test
+ fun `GIVEN no top tokens loaded WHEN convert THEN content with empty suggestions`() {
+ // Arrange
+ val converter = ForYouEarnOpportunitiesTokensActiveConverter(topEarnTokens = null)
+
+ // Act
+ val result = converter.convert(listOf(createEarnOpportunities()))
+
+ // Assert
+ val expected = EarnOpportunitiesUM.Content(
+ tokenList = persistentListOf(),
+ subtitleRes = R.string.for_you_earn_opportunities_all_tokens_active,
+ potentialReward = null,
+ potentialRewardType = null,
+ )
+ assertThat(result).isEqualTo(expected)
+ }
+
+ @Test
+ fun `GIVEN top tokens failed to load WHEN convert THEN suggestions are empty`() {
+ // Arrange
+ val converter = ForYouEarnOpportunitiesTokensActiveConverter(
+ topEarnTokens = EarnError.NotHttpError().left(),
+ )
+
+ // Act
+ val result = converter.convert(listOf(createEarnOpportunities())) as EarnOpportunitiesUM.Content
+
+ // Assert
+ assertThat(result.tokenList).isEmpty()
+ }
+}
\ No newline at end of file
diff --git a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTopTokenRowConverterTest.kt b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTopTokenRowConverterTest.kt
new file mode 100644
index 0000000000..8bd359680d
--- /dev/null
+++ b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTopTokenRowConverterTest.kt
@@ -0,0 +1,94 @@
+package com.tangem.features.foryou.impl.model.converter.earnOpportunities
+
+import com.google.common.truth.Truth.assertThat
+import com.tangem.common.ui.R
+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.EarnType
+import org.junit.jupiter.api.Test
+import java.math.BigDecimal
+
+internal class ForYouEarnOpportunitiesTopTokenRowConverterTest {
+
+ private val converter = ForYouEarnOpportunitiesTopTokenRowConverter()
+
+ @Test
+ fun `GIVEN top-earn token WHEN convert THEN row carries currency identity and network subtitle`() {
+ // Arrange
+ val topToken = createTopEarnToken(
+ tokenId = "solana",
+ networkRawId = "SOL",
+ networkName = "Solana",
+ name = "Solana",
+ apy = "7.25",
+ )
+
+ // Act
+ val result = converter.convert(topToken)
+
+ // Assert
+ val row = result.tokenRowUM as TangemTokenRowUM.Content
+ assertThat(row.id).isEqualTo("solana-SOL")
+ assertThat(row.titleUM).isEqualTo(TangemTokenRowUM.TitleUM.Content(text = stringReference("Solana")))
+ assertThat(row.subtitleUM).isEqualTo(
+ TangemTokenRowUM.SubtitleUM.Content(
+ text = resourceReference(R.string.wallet_network_group_title, wrappedList("Solana")),
+ ),
+ )
+ assertThat(row.topEndContentUM).isEqualTo(
+ TangemTokenRowUM.EndContentUM.Content(
+ text = resourceReference(R.string.markets_apy_placeholder, wrappedList("7.25".expectedPercent())),
+ ),
+ )
+ }
+
+ @Test
+ fun `GIVEN staking token WHEN convert THEN bottom end labels staking`() {
+ // Arrange
+ val topToken = createTopEarnToken(type = EarnType.STAKING)
+
+ // Act
+ val result = converter.convert(topToken)
+
+ // Assert
+ val row = result.tokenRowUM as TangemTokenRowUM.Content
+ assertThat(row.bottomEndContentUM).isEqualTo(
+ TangemTokenRowUM.EndContentUM.Content(text = resourceReference(R.string.common_staking)),
+ )
+ }
+
+ @Test
+ fun `GIVEN yield token WHEN convert THEN bottom end labels yield mode`() {
+ // Arrange
+ val topToken = createTopEarnToken(type = EarnType.YIELD)
+
+ // Act
+ val result = converter.convert(topToken)
+
+ // Assert
+ val row = result.tokenRowUM as TangemTokenRowUM.Content
+ assertThat(row.bottomEndContentUM).isEqualTo(
+ TangemTokenRowUM.EndContentUM.Content(text = resourceReference(R.string.common_yield_mode)),
+ )
+ }
+
+ @Test
+ fun `GIVEN top-earn token WHEN convert THEN item is a flat non-expandable row`() {
+ // Act
+ val result = converter.convert(createTopEarnToken())
+
+ // Assert
+ assertThat(result.isExpandable).isFalse()
+ assertThat(result.isExpanded).isFalse()
+ assertThat(result.tokenList).isEmpty()
+ }
+
+ /** Mirrors the production APY rendering used by [ForYouEarnOpportunitiesTopTokenRowConverter]. */
+ private fun String.expectedPercent(): TextReference =
+ TextReference.Str(BigDecimal(this).format { percent(withPercentSign = false) })
+}
\ No newline at end of file
diff --git a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/transformer/SetPortfolioReviewTransformerTest.kt b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/transformer/SetPortfolioReviewTransformerTest.kt
index 4e6e65f57a..450b82515c 100644
--- a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/transformer/SetPortfolioReviewTransformerTest.kt
+++ b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/transformer/SetPortfolioReviewTransformerTest.kt
@@ -10,6 +10,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.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
@@ -307,6 +308,7 @@ internal class SetPortfolioReviewTransformerTest {
tokenList = persistentListOf(),
marketChartUM = MarketChartUM.NoData,
),
+ earnOpportunities = EarnOpportunitiesUM.Loading(tokenList = persistentListOf()),
notifications = persistentListOf(),
)