Updated on 2026-08-14
This commit is contained in:
commit
819ab8c161
29 changed files with 2069 additions and 51 deletions
|
|
@ -106,7 +106,7 @@
|
|||
<string name="address_book_contact">Contact</string>
|
||||
<string name="address_book_contact_name">Nom du contact</string>
|
||||
<string name="address_book_copy_address">Copier l\'adresse</string>
|
||||
<string name="address_book_create_success_message">Contact sauvegardé</string>
|
||||
<string name="address_book_create_success_message">Contact enregistré</string>
|
||||
<string name="address_book_creating_error">Nous n\'avons pas pu créer le contact. Veuillez réessayer plus tard.</string>
|
||||
<string name="address_book_delete_contact">Supprimer le contact</string>
|
||||
<string name="address_book_delete_contact_description">Ce contact sera supprimé de tous vos carnets d\'adresses</string>
|
||||
|
|
@ -128,6 +128,7 @@
|
|||
<string name="address_book_no_contacts">Aucun contact pour le moment</string>
|
||||
<string name="address_book_no_contacts_description">Les contacts que vous ajouterez vont apparaître ici</string>
|
||||
<string name="address_book_remove_address">Supprimer l\'adresse</string>
|
||||
<string name="address_book_save_address">Enregistrer l\'adresse</string>
|
||||
<string name="address_book_save_contact">Enregistrer le contact</string>
|
||||
<string name="address_book_save_to_wallet_title">Enregistrer dans le portefeuille</string>
|
||||
<string name="address_book_save_wallet_to_description">Ce contact va être lié au carnet d\'adresses de ce portefeuille.</string>
|
||||
|
|
|
|||
|
|
@ -104,6 +104,7 @@
|
|||
<item quantity="other">%d件のアドレス</item>
|
||||
</plurals>
|
||||
<string name="address_book_choose_address">アドレスを選択</string>
|
||||
<string name="address_book_clear_all_networks">すべてクリア</string>
|
||||
<string name="address_book_contact">連絡先</string>
|
||||
<string name="address_book_contact_name">連絡先名</string>
|
||||
<string name="address_book_copy_address">アドレスをコピー</string>
|
||||
|
|
@ -129,10 +130,12 @@
|
|||
<string name="address_book_no_contacts">連絡先はまだありません</string>
|
||||
<string name="address_book_no_contacts_description">追加した連絡先はここに表示されます。</string>
|
||||
<string name="address_book_remove_address">アドレスを削除</string>
|
||||
<string name="address_book_save_address">アドレスを保存</string>
|
||||
<string name="address_book_save_contact">連絡先を保存</string>
|
||||
<string name="address_book_save_to_wallet_title">ウォレットに保存</string>
|
||||
<string name="address_book_save_wallet_to_description">この連絡先は、このウォレットのアドレス帳に紐付けられます。</string>
|
||||
<string name="address_book_search_no_results">結果が見つかりませんでした。\n別の名前を試してください。</string>
|
||||
<string name="address_book_select_all_networks">すべて選択</string>
|
||||
<string name="address_book_select_network">ネットワークを選択</string>
|
||||
<string name="address_book_title">連絡先</string>
|
||||
<string name="address_book_unsaved_changes">保存されていない変更</string>
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@
|
|||
<string name="address_book_contact">Contato</string>
|
||||
<string name="address_book_contact_name">Nome do contato</string>
|
||||
<string name="address_book_copy_address">Copiar endereço</string>
|
||||
<string name="address_book_create_success_message">Contato adicionado</string>
|
||||
<string name="address_book_create_success_message">Contato salvo</string>
|
||||
<string name="address_book_creating_error">Não foi possível estabelecer contato. Tente novamente mais tarde.</string>
|
||||
<string name="address_book_delete_contact">Excluir contato</string>
|
||||
<string name="address_book_delete_contact_description">Este contato será excluído de toda a sua agenda de contatos.</string>
|
||||
|
|
@ -725,6 +725,11 @@
|
|||
<string name="feedback_subject_tx_failed">Não foi possível enviar uma transação.</string>
|
||||
<string name="feedback_token_description_error">Erro na descrição da moeda</string>
|
||||
<string name="for_you_description">Analise seu portfólio e explore oportunidades de ganhos.</string>
|
||||
<string name="for_you_earn_opportunities">Oportunidades de ganhar</string>
|
||||
<string name="for_you_earn_opportunities_all_tokens_active">Todos os seus recursos estão em ação, explore novas oportunidades.</string>
|
||||
<string name="for_you_earn_opportunities_no_available_tokens">Levante-se %1$s anualmente</string>
|
||||
<string name="for_you_earn_opportunities_tokens_rewards">recompensas potenciais máximas %1$s</string>
|
||||
<string name="for_you_earn_per_year">%1$s/ano</string>
|
||||
<string name="for_you_portfolio_review_title">Avaliação de portfólio</string>
|
||||
<string name="for_you_title">Para você</string>
|
||||
<string name="force_update_action">Atualize agora</string>
|
||||
|
|
@ -1919,6 +1924,10 @@
|
|||
<item quantity="one">%d cartão</item>
|
||||
<item quantity="other">%d cartões</item>
|
||||
</plurals>
|
||||
<string name="tangempay_cashback_deactivated_description">Isso ocorreu devido ao seu comportamento suspeito. Entre em contato com o suporte para saber mais.</string>
|
||||
<string name="tangempay_cashback_deactivated_title">Cashback desativado</string>
|
||||
<string name="tangempay_cashback_deposited_on">Será depositado em %1$s</string>
|
||||
<string name="tangempay_cashback_widget_title">%1$s cashback em %2$s</string>
|
||||
<string name="tangempay_change_pin_code">Alterar código PIN</string>
|
||||
<string name="tangempay_come_back_if_forget_pin">Volte ao aplicativo se você se esquecer.</string>
|
||||
<string name="tangempay_common_card">Cartão</string>
|
||||
|
|
@ -2046,6 +2055,7 @@
|
|||
<string name="tangempay_set_pin_header">Configurar novo PIN</string>
|
||||
<string name="tangempay_set_pin_title">Definir PIN</string>
|
||||
<string name="tangempay_status_deactivated">Conta encerrada</string>
|
||||
<string name="tangempay_status_inactive">Inativo</string>
|
||||
<string name="tangempay_status_replacing">Substituindo seu cartão</string>
|
||||
<string name="tangempay_sync_needed">Use o cartão ou anel para renovar a sessão</string>
|
||||
<string name="tangempay_sync_needed_body">Use o cartão ou anel para renovar a sessão</string>
|
||||
|
|
@ -2061,6 +2071,8 @@
|
|||
<string name="tangempay_topup_swap_body">Recarregue sua conta com qualquer token da carteira</string>
|
||||
<string name="tangempay_topup_swap_title">Da sua Tangem Wallet</string>
|
||||
<string name="tangempay_usdc_on_polygon_network">USDC na rede Polygon</string>
|
||||
<string name="tangempay_va_banking_details_error_description">Por favor, tente novamente ou entre em contato com o suporte se o problema persistir.</string>
|
||||
<string name="tangempay_va_banking_details_error_title">Não foi possível carregar os dados bancários.</string>
|
||||
<string name="tangempay_visa_benefits">Benefícios do visto</string>
|
||||
<string name="tangempay_withdrawal_note_description">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</string>
|
||||
<string name="tangempay_withdrawal_note_title">Observe</string>
|
||||
|
|
|
|||
|
|
@ -130,6 +130,7 @@
|
|||
<string name="address_book_no_contacts">尚无联系人</string>
|
||||
<string name="address_book_no_contacts_description">添加的联系人将显示在此处</string>
|
||||
<string name="address_book_remove_address">移除地址</string>
|
||||
<string name="address_book_save_address">保存地址</string>
|
||||
<string name="address_book_save_contact">保存联系人</string>
|
||||
<string name="address_book_save_to_wallet_title">保存到钱包</string>
|
||||
<string name="address_book_save_wallet_to_description">该联系人将与该钱包的通讯录关联。</string>
|
||||
|
|
|
|||
|
|
@ -70,4 +70,5 @@ dependencies {
|
|||
/** Tests */
|
||||
testImplementation(projects.common.test)
|
||||
testImplementation(projects.test.core)
|
||||
testImplementation(projects.test.mock)
|
||||
}
|
||||
|
|
@ -1,15 +1,18 @@
|
|||
package com.tangem.features.foryou.impl.entity
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.tangem.core.ui.ds.row.token.TangemTokenRowUM
|
||||
import com.tangem.core.ui.ds.tabs.TangemSegmentUM
|
||||
import com.tangem.core.ui.ds.tabs.TangemSegmentedPickerUM
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.features.foryou.impl.components.state.MarketChartUM
|
||||
import com.tangem.features.foryou.impl.model.ForYouNotification
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
internal data class ForYouUM(
|
||||
val portfolioReviewUM: PortfolioReviewUM,
|
||||
val earnOpportunities: EarnOpportunitiesUM,
|
||||
val notifications: ImmutableList<ForYouNotification>,
|
||||
)
|
||||
|
||||
|
|
@ -31,6 +34,23 @@ internal sealed interface PortfolioReviewUM {
|
|||
) : PortfolioReviewUM
|
||||
}
|
||||
|
||||
@Immutable
|
||||
internal sealed interface EarnOpportunitiesUM {
|
||||
|
||||
val tokenList: ImmutableList<ForYouTokenListItemUM>
|
||||
|
||||
data class Loading(
|
||||
override val tokenList: ImmutableList<ForYouTokenListItemUM>,
|
||||
) : EarnOpportunitiesUM
|
||||
|
||||
data class Content(
|
||||
override val tokenList: ImmutableList<ForYouTokenListItemUM>,
|
||||
@param:StringRes val subtitleRes: Int,
|
||||
val potentialReward: TextReference?,
|
||||
val potentialRewardType: TextReference?,
|
||||
) : EarnOpportunitiesUM
|
||||
}
|
||||
|
||||
@Immutable
|
||||
internal data class ForYouTokenListItemUM(
|
||||
val tokenRowUM: TangemTokenRowUM,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import com.tangem.domain.models.currency.CryptoCurrency
|
|||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.features.foryou.ForYouComponent
|
||||
import com.tangem.features.foryou.impl.components.state.MarketChartUM
|
||||
import com.tangem.features.foryou.impl.entity.EarnOpportunitiesUM
|
||||
import com.tangem.features.foryou.impl.entity.ForYouTokenListItemUM
|
||||
import com.tangem.features.foryou.impl.entity.ForYouUM
|
||||
import com.tangem.features.foryou.impl.entity.PortfolioReviewUM
|
||||
|
|
@ -45,6 +46,22 @@ internal class ForYouModel @Inject constructor(
|
|||
field = MutableStateFlow<ForYouUM>(
|
||||
ForYouUM(
|
||||
notifications = persistentListOf(),
|
||||
earnOpportunities = EarnOpportunitiesUM.Loading(
|
||||
tokenList = buildList<ForYouTokenListItemUM> {
|
||||
repeat(4) { index ->
|
||||
add(
|
||||
ForYouTokenListItemUM(
|
||||
tokenRowUM = TangemTokenRowUM.Loading(
|
||||
id = index.toString(),
|
||||
),
|
||||
tokenList = persistentListOf(),
|
||||
isExpanded = false,
|
||||
isExpandable = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
}.toPersistentList(),
|
||||
),
|
||||
portfolioReviewUM = PortfolioReviewUM.Loading(
|
||||
marketChartUM = MarketChartUM.NoData,
|
||||
tokenList = buildList<ForYouTokenListItemUM> {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,78 @@
|
|||
package com.tangem.features.foryou.impl.model.converter
|
||||
|
||||
import com.tangem.core.ui.ds.badge.TangemBadgeColor
|
||||
import com.tangem.core.ui.ds.badge.TangemBadgeSize
|
||||
import com.tangem.core.ui.ds.badge.TangemBadgeType
|
||||
import com.tangem.core.ui.ds.badge.TangemBadgeUM
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.domain.models.account.Account
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.utils.extensions.isZero
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
|
||||
/** Number of suggested top-earn tokens shown in the earn-opportunities block. */
|
||||
internal const val FOR_YOU_TOP_EARN_TOKENS_COUNT = 5
|
||||
|
||||
/** Batch size for getting top earn tokens in single and only page */
|
||||
internal const val TOP_EARN_TOKENS_BATCH_SIZE = 30
|
||||
|
||||
/** Divisor converting backend percent values (5.5) to fractions (0.055). */
|
||||
internal val PERCENT_BASE = BigDecimal("100")
|
||||
|
||||
/**
|
||||
* Cross-network grouping key for the portfolio review: the same asset on different networks (e.g. USDC
|
||||
* on Solana and Ethereum) shares its `rawCurrencyId`, so they group under a single item. Custom tokens
|
||||
* have no raw id and fall back to their unique currency id, staying in their own group.
|
||||
*/
|
||||
internal fun CryptoCurrencyStatus.forYouGroupKey(): String = currency.id.rawCurrencyId?.value ?: currency.id.value
|
||||
|
||||
/**
|
||||
* Matching key between a portfolio currency and a top-earn suggestion: the same asset
|
||||
* (`rawCurrencyId`, falling back to the unique id for custom tokens) on the same network.
|
||||
*/
|
||||
internal fun CryptoCurrency.forYouEarnAssetKey(): Pair<String, String> =
|
||||
(id.rawCurrencyId?.value ?: id.value) to network.rawId
|
||||
|
||||
/**
|
||||
* Computes this fiat amount as a share of [totalFiatBalance]. Returns `null` when the share cannot be
|
||||
* computed (no amount, or a zero total / amount).
|
||||
*/
|
||||
internal fun BigDecimal?.toForYouPercent(totalFiatBalance: BigDecimal): BigDecimal? {
|
||||
if (this == null || totalFiatBalance.isZero() || isZero()) return null
|
||||
return divide(totalFiatBalance, RoundingMode.HALF_UP)
|
||||
}
|
||||
|
||||
// TODO For You: replace this placeholder with the real price-change badge once the design is wired.
|
||||
internal fun forYouPlaceholderBadge(): TangemBadgeUM = TangemBadgeUM(
|
||||
text = stringReference("Positive"),
|
||||
size = TangemBadgeSize.X4,
|
||||
type = TangemBadgeType.Tinted,
|
||||
color = TangemBadgeColor.Green,
|
||||
)
|
||||
|
||||
/**
|
||||
* Earn rate resolved for a portfolio currency.
|
||||
*
|
||||
* @property isActive whether the user already earns on the token (active yield supply or stake)
|
||||
* @property apy rate as a fraction (0.05 = 5%)
|
||||
* @property potentialRewards projected yearly reward in fiat (`fiatAmount * apy`), `null` when unknown
|
||||
*/
|
||||
internal data class EarnApyInfo(
|
||||
val isActive: Boolean,
|
||||
val apy: BigDecimal?,
|
||||
val potentialRewards: BigDecimal?,
|
||||
)
|
||||
|
||||
/**
|
||||
* Earn-eligible currencies of one account with their resolved rates.
|
||||
*
|
||||
* @property accountPotentialReward sum of [EarnApyInfo.potentialRewards] over [earnCurrencues];
|
||||
* accounts are ordered by it, descending
|
||||
*/
|
||||
internal data class EarnOpportunities(
|
||||
val account: Account.CryptoPortfolio,
|
||||
val earnCurrencues: Map<CryptoCurrencyStatus, EarnApyInfo>,
|
||||
val accountPotentialReward: BigDecimal,
|
||||
)
|
||||
|
|
@ -0,0 +1,213 @@
|
|||
package com.tangem.features.foryou.impl.model.converter.earnOpportunities
|
||||
|
||||
import com.tangem.domain.account.models.AccountStatusList
|
||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||
import com.tangem.domain.models.account.filterCryptoPortfolio
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.models.currency.yieldSupplyKey
|
||||
import com.tangem.domain.models.earn.EarnTopToken
|
||||
import com.tangem.domain.models.staking.StakingBalance
|
||||
import com.tangem.domain.staking.model.StakingAvailability
|
||||
import com.tangem.domain.staking.model.StakingOption
|
||||
import com.tangem.domain.staking.model.common.RewardInfo
|
||||
import com.tangem.domain.staking.model.common.RewardType
|
||||
import com.tangem.domain.staking.model.optionOrNull
|
||||
import com.tangem.features.foryou.impl.entity.EarnOpportunitiesUM
|
||||
import com.tangem.features.foryou.impl.model.converter.EarnApyInfo
|
||||
import com.tangem.features.foryou.impl.model.converter.EarnOpportunities
|
||||
import com.tangem.features.foryou.impl.model.converter.PERCENT_BASE
|
||||
import com.tangem.lib.crypto.BlockchainUtils
|
||||
import com.tangem.utils.converter.Converter
|
||||
import com.tangem.utils.extensions.orZero
|
||||
import com.tangem.utils.isNullOrZero
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
|
||||
/**
|
||||
* Builds the For You earn-opportunities section. For every portfolio currency it resolves an earn rate
|
||||
* (see [resolveEarnApy]) and keeps only tokens that could earn (positive fiat balance) or already do
|
||||
* (active yield supply / stake).
|
||||
*
|
||||
* The section state is then picked from the result:
|
||||
* - nothing is earn-eligible → [ForYouEarnOpportunitiesNoTokensConverter] (suggests [topEarnTokens]);
|
||||
* - every eligible token already earns → [ForYouEarnOpportunitiesTokensActiveConverter]
|
||||
* (suggests [topEarnTokens] the user is not earning on yet);
|
||||
* - otherwise → [ForYouEarnOpportunitiesPotentialRewardsConverter] (per-account potential rewards,
|
||||
* accounts sorted by reward descending).
|
||||
*/
|
||||
@Suppress("LongParameterList")
|
||||
internal class ForYouEarnOpportunitiesConverter(
|
||||
private val appCurrency: AppCurrency,
|
||||
private val isAccountsModeEnabled: Boolean,
|
||||
private val expandedAssetIds: Set<String>,
|
||||
private val expandClick: (assetId: String) -> Unit,
|
||||
private val yieldSupplyAvailability: Map<String, BigDecimal>,
|
||||
private val yieldStakingAvailability: Map<CryptoCurrency, StakingAvailability>,
|
||||
private val topEarnTokens: EarnTopToken?,
|
||||
) : Converter<AccountStatusList?, EarnOpportunitiesUM> {
|
||||
|
||||
override fun convert(value: AccountStatusList?): EarnOpportunitiesUM {
|
||||
val data = value?.accountStatuses
|
||||
?.filterCryptoPortfolio()
|
||||
?.asSequence()
|
||||
?.mapNotNull { cryptoAccountStatus ->
|
||||
val tokenList =
|
||||
cryptoAccountStatus.flattenCurrencies().mapNotNull { cryptoCurrencyStatus ->
|
||||
val earn = resolveEarnApy(
|
||||
cryptoCurrencyStatus = cryptoCurrencyStatus,
|
||||
yieldModuleApyMap = yieldSupplyAvailability,
|
||||
stakingApyMap = yieldStakingAvailability,
|
||||
)
|
||||
|
||||
if (earn == null || cryptoCurrencyStatus.value.fiatAmount.isNullOrZero() && !earn.isActive) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
|
||||
cryptoCurrencyStatus to earn
|
||||
}
|
||||
|
||||
if (tokenList.isEmpty()) return@mapNotNull null
|
||||
|
||||
val accountPotentialReward = tokenList.sumOf { (_, earn) ->
|
||||
earn.potentialRewards.orZero()
|
||||
}
|
||||
|
||||
EarnOpportunities(
|
||||
account = cryptoAccountStatus.account,
|
||||
earnCurrencues = tokenList.toMap(),
|
||||
accountPotentialReward = accountPotentialReward,
|
||||
)
|
||||
}
|
||||
?.sortedByDescending { it.accountPotentialReward }
|
||||
.orEmpty().toList()
|
||||
|
||||
return when {
|
||||
data.isEmpty() -> {
|
||||
ForYouEarnOpportunitiesNoTokensConverter(topEarnTokens).convert(data)
|
||||
}
|
||||
data.all { earn -> earn.earnCurrencues.all { entry -> entry.value.isActive } } -> {
|
||||
ForYouEarnOpportunitiesTokensActiveConverter(topEarnTokens).convert(data)
|
||||
}
|
||||
else -> {
|
||||
ForYouEarnOpportunitiesPotentialRewardsConverter(
|
||||
appCurrency = appCurrency,
|
||||
isAccountsModeEnabled = isAccountsModeEnabled,
|
||||
expandedAssetIds = expandedAssetIds,
|
||||
expandClick = expandClick,
|
||||
).convert(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the earn rate for a currency; yield supply takes precedence over staking. The returned
|
||||
* [EarnApyInfo.apy] is a fraction (0.05 = 5%): yield APY arrives from the backend in percent and is
|
||||
* scaled down by [PERCENT_BASE]. Returns `null` when the token cannot earn at all.
|
||||
*/
|
||||
private fun resolveEarnApy(
|
||||
cryptoCurrencyStatus: CryptoCurrencyStatus,
|
||||
yieldModuleApyMap: Map<String, BigDecimal>,
|
||||
stakingApyMap: Map<CryptoCurrency, StakingAvailability>,
|
||||
): EarnApyInfo? {
|
||||
val token = cryptoCurrencyStatus.currency as? CryptoCurrency.Token
|
||||
if (token != null && yieldModuleApyMap.isNotEmpty()) {
|
||||
val yieldSupplyApy = yieldModuleApyMap.entries.firstOrNull { (tokenId, _) ->
|
||||
tokenId.equals(
|
||||
other = token.yieldSupplyKey(),
|
||||
ignoreCase = BlockchainUtils.isCaseInsensitiveContractAddress(token.network.rawId),
|
||||
)
|
||||
}?.value
|
||||
if (yieldSupplyApy != null) {
|
||||
val isActive = cryptoCurrencyStatus.value.yieldSupplyStatus?.isActive == true
|
||||
val apy = yieldSupplyApy.divide(PERCENT_BASE, RoundingMode.HALF_UP)
|
||||
return EarnApyInfo(
|
||||
isActive = isActive,
|
||||
potentialRewards = cryptoCurrencyStatus.value.fiatAmount?.multiply(apy),
|
||||
apy = apy,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (stakingApyMap.isNotEmpty()) {
|
||||
val stakingInfo = findStakingRate(
|
||||
currencyStatus = cryptoCurrencyStatus,
|
||||
stakingApyMap = stakingApyMap,
|
||||
)
|
||||
if (stakingInfo.rate != null) {
|
||||
return EarnApyInfo(
|
||||
isActive = stakingInfo.isActive,
|
||||
apy = stakingInfo.rate,
|
||||
potentialRewards = cryptoCurrencyStatus.value.fiatAmount?.multiply(stakingInfo.rate),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks the staking rate to display: for an active StakeKit stake — the rate of the validator the user
|
||||
* actually stakes with (falling back to the best preferred one), otherwise the best preferred
|
||||
* validator's rate. [StakingAvailability.Full] pools are surfaced only for tokens already staked.
|
||||
*/
|
||||
private fun findStakingRate(
|
||||
currencyStatus: CryptoCurrencyStatus,
|
||||
stakingApyMap: Map<CryptoCurrency, StakingAvailability>,
|
||||
): StakingLocalInfo {
|
||||
val availability = stakingApyMap[currencyStatus.currency]
|
||||
val option = availability?.optionOrNull
|
||||
?: return StakingLocalInfo(rate = null, isActive = false, rewardType = null)
|
||||
|
||||
val stakingBalance = currencyStatus.value.stakingBalance as? StakingBalance.Data
|
||||
val stakeKitBalance = stakingBalance as? StakingBalance.Data.StakeKit
|
||||
val p2pEthPoolBalance = stakingBalance as? StakingBalance.Data.P2PEthPool
|
||||
val isActive = stakeKitBalance != null || p2pEthPoolBalance != null
|
||||
|
||||
// Full = no free capacity: show the badge only for tokens that already have a stake.
|
||||
if (availability is StakingAvailability.Full && !isActive) {
|
||||
return StakingLocalInfo(rate = null, isActive = false, rewardType = null)
|
||||
}
|
||||
|
||||
val rateInfo = when (option) {
|
||||
is StakingOption.P2PEthPool -> {
|
||||
RewardInfo(
|
||||
rate = option.apy,
|
||||
type = RewardType.APY,
|
||||
)
|
||||
}
|
||||
is StakingOption.StakeKit -> if (stakeKitBalance != null) {
|
||||
val validatorsByAddress = option.yield.validators.associateBy { it.address }
|
||||
stakeKitBalance.balance.items
|
||||
.mapNotNull { it.validatorAddress }
|
||||
.firstNotNullOfOrNull { address ->
|
||||
validatorsByAddress[address]?.rewardInfo
|
||||
} ?: option.yield.validators
|
||||
.filter { it.preferred }
|
||||
.mapNotNull { validator ->
|
||||
validator.rewardInfo
|
||||
}
|
||||
.maxByOrNull { it.rate }
|
||||
} else {
|
||||
option.yield.validators
|
||||
.filter { it.preferred }
|
||||
.mapNotNull { validator ->
|
||||
validator.rewardInfo
|
||||
}
|
||||
.maxByOrNull { it.rate }
|
||||
}
|
||||
}
|
||||
|
||||
return StakingLocalInfo(
|
||||
rate = rateInfo?.rate,
|
||||
isActive = isActive,
|
||||
rewardType = rateInfo?.type,
|
||||
)
|
||||
}
|
||||
|
||||
private data class StakingLocalInfo(
|
||||
val rate: BigDecimal?,
|
||||
val isActive: Boolean,
|
||||
val rewardType: RewardType?,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
package com.tangem.features.foryou.impl.model.converter.earnOpportunities
|
||||
|
||||
import com.tangem.common.ui.R
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.core.ui.format.bigdecimal.format
|
||||
import com.tangem.core.ui.format.bigdecimal.percent
|
||||
import com.tangem.core.ui.utils.parseBigDecimalOrNull
|
||||
import com.tangem.domain.models.earn.EarnTopToken
|
||||
import com.tangem.features.foryou.impl.entity.EarnOpportunitiesUM
|
||||
import com.tangem.features.foryou.impl.model.converter.EarnOpportunities
|
||||
import com.tangem.features.foryou.impl.model.converter.FOR_YOU_TOP_EARN_TOKENS_COUNT
|
||||
import com.tangem.utils.converter.Converter
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
|
||||
/**
|
||||
* Earn-opportunities state for a portfolio with nothing earn-eligible: suggests the top
|
||||
* [FOR_YOU_TOP_EARN_TOKENS_COUNT] earn tokens, headed by the best (first) suggestion's yearly rate
|
||||
* ([EarnOpportunitiesUM.Content.potentialReward]) and its reward type, APR/APY
|
||||
* ([EarnOpportunitiesUM.Content.potentialRewardType]).
|
||||
*/
|
||||
internal class ForYouEarnOpportunitiesNoTokensConverter(
|
||||
private val topEarnTokens: EarnTopToken?,
|
||||
) : Converter<List<EarnOpportunities>, EarnOpportunitiesUM> {
|
||||
|
||||
override fun convert(value: List<EarnOpportunities>): EarnOpportunitiesUM {
|
||||
val topEarnTokenList = topEarnTokens?.getOrNull()
|
||||
?.take(FOR_YOU_TOP_EARN_TOKENS_COUNT)
|
||||
|
||||
val topEarnToken = topEarnTokenList?.firstOrNull()?.earnToken
|
||||
val topEarnApy = topEarnToken?.apy?.parseBigDecimalOrNull()
|
||||
|
||||
val rowConverter = ForYouEarnOpportunitiesTopTokenRowConverter()
|
||||
|
||||
return EarnOpportunitiesUM.Content(
|
||||
tokenList = topEarnTokenList
|
||||
?.map(rowConverter::convert)
|
||||
.orEmpty()
|
||||
.toPersistentList(),
|
||||
subtitleRes = R.string.for_you_earn_opportunities_no_available_tokens,
|
||||
potentialReward = stringReference(topEarnApy.format { percent() }),
|
||||
potentialRewardType = topEarnToken?.rewardType?.name?.let(::stringReference),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
package com.tangem.features.foryou.impl.model.converter.earnOpportunities
|
||||
|
||||
import com.tangem.common.ui.R
|
||||
import com.tangem.common.ui.account.AccountIconItemStateConverter
|
||||
import com.tangem.common.ui.account.toUM
|
||||
import com.tangem.core.ui.components.account.AccountIconSize
|
||||
import com.tangem.core.ui.ds.image.TangemIconUM
|
||||
import com.tangem.core.ui.ds.row.token.TangemTokenRowUM
|
||||
import com.tangem.core.ui.extensions.*
|
||||
import com.tangem.core.ui.format.bigdecimal.fiat
|
||||
import com.tangem.core.ui.format.bigdecimal.format
|
||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||
import com.tangem.domain.models.account.Account
|
||||
import com.tangem.features.foryou.impl.entity.EarnOpportunitiesUM
|
||||
import com.tangem.features.foryou.impl.entity.ForYouTokenListItemUM
|
||||
import com.tangem.features.foryou.impl.model.converter.EarnOpportunities
|
||||
import com.tangem.utils.StringsSigns
|
||||
import com.tangem.utils.converter.Converter
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
import java.math.BigDecimal
|
||||
|
||||
internal class ForYouEarnOpportunitiesPotentialRewardsConverter(
|
||||
private val appCurrency: AppCurrency,
|
||||
private val isAccountsModeEnabled: Boolean,
|
||||
private val expandedAssetIds: Set<String>,
|
||||
private val expandClick: (assetId: String) -> Unit,
|
||||
) : Converter<List<EarnOpportunities>, EarnOpportunitiesUM> {
|
||||
|
||||
private val rowConverter = ForYouEarnOpportunitiesTokenRowConverter(appCurrency = appCurrency)
|
||||
|
||||
override fun convert(value: List<EarnOpportunities>): EarnOpportunitiesUM {
|
||||
val totalPotentialReward = value.sumOf { it.accountPotentialReward }
|
||||
val totalPotentialRewardText = resourceReference(
|
||||
R.string.for_you_earn_per_year,
|
||||
wrappedList(
|
||||
totalPotentialReward.format {
|
||||
fiat(
|
||||
fiatCurrencySymbol = appCurrency.symbol,
|
||||
fiatCurrencyCode = appCurrency.code,
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
return EarnOpportunitiesUM.Content(
|
||||
tokenList = value.flatMap { earnData ->
|
||||
if (isAccountsModeEnabled) {
|
||||
listOf(
|
||||
ForYouTokenListItemUM(
|
||||
tokenRowUM = createAssetRow(
|
||||
account = earnData.account,
|
||||
potentialReward = earnData.accountPotentialReward,
|
||||
tokenCount = earnData.earnCurrencues.size,
|
||||
),
|
||||
tokenList = rowConverter.convertList(earnData.earnCurrencues.toList())
|
||||
.toPersistentList(),
|
||||
isExpanded = earnData.account.accountId.value in expandedAssetIds,
|
||||
isExpandable = true,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
earnData.earnCurrencues.map { token ->
|
||||
ForYouTokenListItemUM(
|
||||
tokenRowUM = rowConverter.convert(token.toPair()),
|
||||
tokenList = persistentListOf(),
|
||||
isExpanded = false,
|
||||
isExpandable = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}.toPersistentList(),
|
||||
subtitleRes = R.string.for_you_earn_opportunities_tokens_rewards,
|
||||
potentialReward = totalPotentialRewardText,
|
||||
potentialRewardType = null,
|
||||
)
|
||||
}
|
||||
|
||||
private fun createAssetRow(
|
||||
account: Account.CryptoPortfolio,
|
||||
potentialReward: BigDecimal?,
|
||||
tokenCount: Int,
|
||||
): TangemTokenRowUM {
|
||||
return TangemTokenRowUM.Content(
|
||||
id = account.accountId.value,
|
||||
headIconUM = TangemIconUM.Currency(
|
||||
currencyIconState = AccountIconItemStateConverter(size = AccountIconSize.RedesignedDefault)
|
||||
.convert(account),
|
||||
),
|
||||
titleUM = TangemTokenRowUM.TitleUM.Content(
|
||||
text = account.accountName.toUM().value,
|
||||
),
|
||||
subtitleUM = TangemTokenRowUM.SubtitleUM.Content(
|
||||
text = pluralReference(
|
||||
R.plurals.common_tokens_count,
|
||||
count = tokenCount,
|
||||
formatArgs = wrappedList(tokenCount),
|
||||
),
|
||||
),
|
||||
topEndContentUM = TangemTokenRowUM.EndContentUM.Content(
|
||||
text = combinedReference(
|
||||
stringReference(StringsSigns.PLUS),
|
||||
resourceReference(
|
||||
R.string.for_you_earn_per_year,
|
||||
wrappedList(
|
||||
potentialReward.format {
|
||||
fiat(
|
||||
fiatCurrencySymbol = appCurrency.symbol,
|
||||
fiatCurrencyCode = appCurrency.code,
|
||||
)
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
bottomEndContentUM = TangemTokenRowUM.EndContentUM.Empty,
|
||||
onItemClick = { expandClick(account.accountId.value) },
|
||||
onItemLongClick = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
package com.tangem.features.foryou.impl.model.converter.earnOpportunities
|
||||
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
|
||||
import com.tangem.common.ui.tokens.TokenItemStateConverter.Companion.isFlickering
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.ds.image.TangemIconUM
|
||||
import com.tangem.core.ui.ds.row.token.TangemTokenRowUM
|
||||
import com.tangem.core.ui.ds.row.token.TangemTokenRowUM.SubtitleUM.Content
|
||||
import com.tangem.core.ui.extensions.*
|
||||
import com.tangem.core.ui.format.bigdecimal.fiat
|
||||
import com.tangem.core.ui.format.bigdecimal.format
|
||||
import com.tangem.core.ui.format.bigdecimal.percent
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||
import com.tangem.domain.models.StatusSource
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.features.foryou.impl.model.converter.EarnApyInfo
|
||||
import com.tangem.utils.StringsSigns
|
||||
import com.tangem.utils.converter.Converter
|
||||
import com.tangem.utils.extensions.orZero
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import java.math.BigDecimal
|
||||
|
||||
internal class ForYouEarnOpportunitiesTokenRowConverter(
|
||||
private val appCurrency: AppCurrency,
|
||||
) : Converter<Pair<CryptoCurrencyStatus, EarnApyInfo>, TangemTokenRowUM> {
|
||||
|
||||
private val iconConverter = CryptoCurrencyToIconStateConverter()
|
||||
|
||||
override fun convert(value: Pair<CryptoCurrencyStatus, EarnApyInfo>): TangemTokenRowUM {
|
||||
val (cryptoCurrencyStatus, earnApyInfo) = value
|
||||
if (cryptoCurrencyStatus.value is CryptoCurrencyStatus.Loading) {
|
||||
return TangemTokenRowUM.Loading(id = cryptoCurrencyStatus.currency.id.value)
|
||||
}
|
||||
|
||||
val possibleEarnAmount = cryptoCurrencyStatus.value.fiatAmount.orZero().multiply(earnApyInfo.apy.orZero())
|
||||
|
||||
return TangemTokenRowUM.Content(
|
||||
id = cryptoCurrencyStatus.currency.id.value,
|
||||
headIconUM = TangemIconUM.Currency(iconConverter.convert(cryptoCurrencyStatus)),
|
||||
titleUM = TangemTokenRowUM.TitleUM.Content(text = stringReference(cryptoCurrencyStatus.currency.name)),
|
||||
subtitleUM = Content(
|
||||
text = resourceReference(
|
||||
R.string.wallet_network_group_title,
|
||||
wrappedList(cryptoCurrencyStatus.currency.network.name),
|
||||
),
|
||||
),
|
||||
topEndContentUM = toRowTopEnd(cryptoCurrencyStatus, possibleEarnAmount),
|
||||
bottomEndContentUM = toRowBottomEnd(cryptoCurrencyStatus, earnApyInfo.apy.orZero()),
|
||||
onItemClick = null,
|
||||
onItemLongClick = null,
|
||||
)
|
||||
}
|
||||
|
||||
/** Top-end: fiat total for resolved states, dash / unreachable treatment otherwise. */
|
||||
private fun toRowTopEnd(
|
||||
cryptoCurrencyStatus: CryptoCurrencyStatus,
|
||||
possibleEarnAmount: BigDecimal,
|
||||
): TangemTokenRowUM.EndContentUM = when (cryptoCurrencyStatus.value) {
|
||||
CryptoCurrencyStatus.Loading -> TangemTokenRowUM.EndContentUM.Loading
|
||||
is CryptoCurrencyStatus.Custom,
|
||||
is CryptoCurrencyStatus.Loaded,
|
||||
is CryptoCurrencyStatus.NoAccount,
|
||||
-> {
|
||||
val possibleEarn = possibleEarnAmount.format {
|
||||
fiat(
|
||||
fiatCurrencyCode = appCurrency.code,
|
||||
fiatCurrencySymbol = appCurrency.symbol,
|
||||
)
|
||||
}
|
||||
|
||||
TangemTokenRowUM.EndContentUM.Content(
|
||||
text = combinedReference(
|
||||
stringReference(StringsSigns.PLUS),
|
||||
resourceReference(
|
||||
R.string.for_you_earn_per_year,
|
||||
wrappedList(possibleEarn),
|
||||
),
|
||||
),
|
||||
isFlickering = cryptoCurrencyStatus.value.isFlickering(),
|
||||
startIcons = buildList {
|
||||
if (cryptoCurrencyStatus.value.sources.total == StatusSource.ONLY_CACHE) {
|
||||
add(
|
||||
TangemIconUM.Icon(
|
||||
iconRes = R.drawable.ic_error_sync_default_24,
|
||||
tintReference = { TangemTheme.colors3.icon.tertiary },
|
||||
),
|
||||
)
|
||||
}
|
||||
}.toImmutableList(),
|
||||
)
|
||||
}
|
||||
is CryptoCurrencyStatus.NoQuote,
|
||||
is CryptoCurrencyStatus.MissedDerivation,
|
||||
is CryptoCurrencyStatus.NoAmount,
|
||||
is CryptoCurrencyStatus.Unreachable,
|
||||
-> TangemTokenRowUM.EndContentUM.Content(text = stringReference(StringsSigns.DASH_SIGN))
|
||||
}
|
||||
|
||||
/** Bottom-end: percentage share for resolved states, no-address / unreachable treatment otherwise. */
|
||||
private fun toRowBottomEnd(
|
||||
cryptoCurrencyStatus: CryptoCurrencyStatus,
|
||||
earnRate: BigDecimal,
|
||||
): TangemTokenRowUM.EndContentUM = when (cryptoCurrencyStatus.value) {
|
||||
CryptoCurrencyStatus.Loading -> TangemTokenRowUM.EndContentUM.Loading
|
||||
is CryptoCurrencyStatus.Custom,
|
||||
is CryptoCurrencyStatus.Loaded,
|
||||
is CryptoCurrencyStatus.NoAccount,
|
||||
-> {
|
||||
TangemTokenRowUM.EndContentUM.Content(
|
||||
text = styledStringReference(
|
||||
value = earnRate.format { percent() },
|
||||
spanStyleReference = { SpanStyle(color = TangemTheme.colors3.text.status.success) },
|
||||
),
|
||||
isFlickering = cryptoCurrencyStatus.value.isFlickering(),
|
||||
startIcons = buildList {
|
||||
if (cryptoCurrencyStatus.value.sources.total == StatusSource.ONLY_CACHE) {
|
||||
add(
|
||||
TangemIconUM.Icon(
|
||||
iconRes = R.drawable.ic_error_sync_default_24,
|
||||
tintReference = { TangemTheme.colors3.icon.tertiary },
|
||||
),
|
||||
)
|
||||
}
|
||||
}.toImmutableList(),
|
||||
)
|
||||
}
|
||||
is CryptoCurrencyStatus.NoQuote,
|
||||
is CryptoCurrencyStatus.MissedDerivation,
|
||||
is CryptoCurrencyStatus.NoAmount,
|
||||
is CryptoCurrencyStatus.Unreachable,
|
||||
-> TangemTokenRowUM.EndContentUM.Content(text = stringReference(StringsSigns.DASH_SIGN))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
package com.tangem.features.foryou.impl.model.converter.earnOpportunities
|
||||
|
||||
import com.tangem.common.ui.R
|
||||
import com.tangem.domain.models.earn.EarnTopToken
|
||||
import com.tangem.features.foryou.impl.entity.EarnOpportunitiesUM
|
||||
import com.tangem.features.foryou.impl.model.converter.EarnOpportunities
|
||||
import com.tangem.features.foryou.impl.model.converter.FOR_YOU_TOP_EARN_TOKENS_COUNT
|
||||
import com.tangem.features.foryou.impl.model.converter.forYouEarnAssetKey
|
||||
import com.tangem.utils.converter.Converter
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
|
||||
/**
|
||||
* Earn-opportunities state for a portfolio where every earn-eligible token is already active:
|
||||
* suggests the top earn tokens the user is not earning on yet (the ones already active are filtered out).
|
||||
*
|
||||
* Matching is per asset **and** network (see [forYouEarnAssetKey]), so an asset active on one network
|
||||
* can still be suggested on another. Active tokens are filtered out before the
|
||||
* [FOR_YOU_TOP_EARN_TOKENS_COUNT] cap, so exclusions don't shrink the suggestion list while more
|
||||
* candidates remain in the batch.
|
||||
*/
|
||||
internal class ForYouEarnOpportunitiesTokensActiveConverter(
|
||||
private val topEarnTokens: EarnTopToken?,
|
||||
) : Converter<List<EarnOpportunities>, EarnOpportunitiesUM> {
|
||||
|
||||
override fun convert(value: List<EarnOpportunities>): EarnOpportunitiesUM {
|
||||
val activeAssetKeys = value
|
||||
.flatMap { opportunities -> opportunities.earnCurrencues.keys }
|
||||
.map { status -> status.currency.forYouEarnAssetKey() }
|
||||
.toSet()
|
||||
|
||||
val rowConverter = ForYouEarnOpportunitiesTopTokenRowConverter()
|
||||
|
||||
return EarnOpportunitiesUM.Content(
|
||||
tokenList = topEarnTokens?.getOrNull()
|
||||
?.filterNot { topToken -> topToken.cryptoCurrency.forYouEarnAssetKey() in activeAssetKeys }
|
||||
?.take(FOR_YOU_TOP_EARN_TOKENS_COUNT)
|
||||
?.map(rowConverter::convert)
|
||||
.orEmpty()
|
||||
.toPersistentList(),
|
||||
subtitleRes = R.string.for_you_earn_opportunities_all_tokens_active,
|
||||
potentialReward = null,
|
||||
potentialRewardType = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
package com.tangem.features.foryou.impl.model.converter.earnOpportunities
|
||||
|
||||
import com.tangem.common.ui.R
|
||||
import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
|
||||
import com.tangem.core.ui.ds.image.TangemIconUM
|
||||
import com.tangem.core.ui.ds.row.token.TangemTokenRowUM
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.core.ui.extensions.wrappedList
|
||||
import com.tangem.core.ui.format.bigdecimal.format
|
||||
import com.tangem.core.ui.format.bigdecimal.percent
|
||||
import com.tangem.domain.models.earn.EarnTokenWithCurrency
|
||||
import com.tangem.domain.models.earn.EarnType
|
||||
import com.tangem.features.foryou.impl.entity.ForYouTokenListItemUM
|
||||
import com.tangem.utils.converter.Converter
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
* Maps a top-earn token (an opportunity the user doesn't hold yet) to a For You list item:
|
||||
* network subtitle, APY top-end and earn-type bottom-end. Shared by the earn-opportunities
|
||||
* converters that surface suggestions ([ForYouEarnOpportunitiesNoTokensConverter],
|
||||
* [ForYouEarnOpportunitiesTokensActiveConverter]).
|
||||
*/
|
||||
internal class ForYouEarnOpportunitiesTopTokenRowConverter : Converter<EarnTokenWithCurrency, ForYouTokenListItemUM> {
|
||||
|
||||
private val iconConverter = CryptoCurrencyToIconStateConverter()
|
||||
|
||||
override fun convert(value: EarnTokenWithCurrency): ForYouTokenListItemUM {
|
||||
val (networkName, earnToken, cryptoCurrency) = value
|
||||
|
||||
return ForYouTokenListItemUM(
|
||||
tokenRowUM = TangemTokenRowUM.Content(
|
||||
id = cryptoCurrency.id.value,
|
||||
headIconUM = TangemIconUM.Currency(iconConverter.convert(cryptoCurrency)),
|
||||
titleUM = TangemTokenRowUM.TitleUM.Content(
|
||||
text = stringReference(cryptoCurrency.name),
|
||||
),
|
||||
subtitleUM = TangemTokenRowUM.SubtitleUM.Content(
|
||||
text = resourceReference(
|
||||
R.string.wallet_network_group_title,
|
||||
wrappedList(networkName),
|
||||
),
|
||||
),
|
||||
topEndContentUM = TangemTokenRowUM.EndContentUM.Content(
|
||||
text = resourceReference(
|
||||
R.string.markets_apy_placeholder,
|
||||
wrappedList(convertPercent(earnToken.apy)),
|
||||
),
|
||||
),
|
||||
bottomEndContentUM = TangemTokenRowUM.EndContentUM.Content(
|
||||
text = resourceReference(
|
||||
when (earnToken.type) {
|
||||
EarnType.STAKING -> R.string.common_staking
|
||||
EarnType.YIELD -> R.string.common_yield_mode
|
||||
},
|
||||
),
|
||||
),
|
||||
onItemClick = {},
|
||||
onItemLongClick = { _, _ -> },
|
||||
),
|
||||
tokenList = persistentListOf(),
|
||||
isExpanded = false,
|
||||
isExpandable = false,
|
||||
)
|
||||
}
|
||||
|
||||
private fun convertPercent(value: String): TextReference {
|
||||
val percent = BigDecimal(value).format { percent(withPercentSign = false) }
|
||||
return TextReference.Str(percent)
|
||||
}
|
||||
}
|
||||
|
|
@ -26,6 +26,7 @@ import com.tangem.core.ui.res.TangemTheme
|
|||
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
||||
import com.tangem.features.foryou.impl.entity.ForYouUM
|
||||
import com.tangem.features.foryou.impl.model.ForYouNotification
|
||||
import com.tangem.features.foryou.impl.ui.preview.ForYouEarnOpportunitiesPreviewData
|
||||
import com.tangem.features.foryou.impl.ui.preview.ForYouPortfolioReviewPreviewData
|
||||
import com.tangem.features.promobanners.api.PromoBannersBlockComponent
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
|
@ -111,6 +112,7 @@ private class ForYouContentPreviewProvider : PreviewParameterProvider<ForYouUM>
|
|||
get() = sequenceOf(
|
||||
ForYouUM(
|
||||
notifications = persistentListOf(ForYouNotification.UsedOutdatedData),
|
||||
earnOpportunities = ForYouEarnOpportunitiesPreviewData.tokensRewards,
|
||||
portfolioReviewUM = ForYouPortfolioReviewPreviewData.reviewContent,
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,157 @@
|
|||
package com.tangem.features.foryou.impl.ui
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.InlineTextContent
|
||||
import androidx.compose.foundation.text.appendInlineContent
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.Placeholder
|
||||
import androidx.compose.ui.text.PlaceholderVerticalAlign
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tangem.core.ui.ds2.shimmers.TangemShimmer
|
||||
import com.tangem.core.ui.extensions.*
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
||||
import com.tangem.features.foryou.impl.R
|
||||
import com.tangem.features.foryou.impl.entity.EarnOpportunitiesUM
|
||||
import com.tangem.features.foryou.impl.ui.components.ForYouPortfolioTokenList
|
||||
import com.tangem.features.foryou.impl.ui.preview.ForYouEarnOpportunitiesPreviewData
|
||||
|
||||
private const val INLINE_CONTENT_PADDING_COEF = 2.2f
|
||||
|
||||
@Composable
|
||||
internal fun ForYouEarnOpportunities(earnOpportunitiesUM: EarnOpportunitiesUM, modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier,
|
||||
) {
|
||||
Text(
|
||||
text = stringResourceSafe(R.string.for_you_earn_opportunities),
|
||||
style = TangemTheme.typography3.heading.small,
|
||||
color = TangemTheme.colors3.text.secondary,
|
||||
)
|
||||
|
||||
when (earnOpportunitiesUM) {
|
||||
is EarnOpportunitiesUM.Content -> {
|
||||
val (textContent, inlineContent) = if (earnOpportunitiesUM.potentialReward != null) {
|
||||
val potentialReward = earnOpportunitiesUM.potentialReward.resolveReference()
|
||||
|
||||
val inlineContent = rememberEarnInlineContent(potentialReward)
|
||||
|
||||
val textContent = resourceReference(
|
||||
earnOpportunitiesUM.subtitleRes,
|
||||
wrappedList(
|
||||
annotatedReference {
|
||||
if (earnOpportunitiesUM.potentialRewardType != null) {
|
||||
append(earnOpportunitiesUM.potentialRewardType.resolveAnnotatedReference())
|
||||
appendSpace()
|
||||
}
|
||||
appendInlineContent(potentialReward, potentialReward)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
textContent to inlineContent
|
||||
} else {
|
||||
resourceReference(earnOpportunitiesUM.subtitleRes) to emptyMap()
|
||||
}
|
||||
|
||||
Text(
|
||||
text = textContent.resolveAnnotatedReference(),
|
||||
inlineContent = inlineContent,
|
||||
style = TangemTheme.typography3.heading.small,
|
||||
color = TangemTheme.colors3.text.primary,
|
||||
)
|
||||
}
|
||||
is EarnOpportunitiesUM.Loading -> {
|
||||
TangemShimmer(
|
||||
style = TangemTheme.typography3.heading.small,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
ForYouPortfolioTokenList(
|
||||
tokenList = earnOpportunitiesUM.tokenList,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rememberEarnInlineContent(potentialReward: String): Map<String, InlineTextContent> {
|
||||
val density = LocalDensity.current
|
||||
val textMeasurer = rememberTextMeasurer()
|
||||
val horizontalPadding = 2.dp
|
||||
|
||||
val potentialStyle = TangemTheme.typography3.heading.small
|
||||
|
||||
val potentialLabelBgWidthSp = remember(potentialReward) {
|
||||
val textLayoutMeasure = textMeasurer.measure(
|
||||
text = potentialReward,
|
||||
style = potentialStyle,
|
||||
)
|
||||
with(density) {
|
||||
(textLayoutMeasure.size.width.toDp() + (horizontalPadding.value * INLINE_CONTENT_PADDING_COEF).dp).toSp()
|
||||
}
|
||||
}
|
||||
|
||||
return remember(potentialReward, potentialLabelBgWidthSp) {
|
||||
mapOf(
|
||||
potentialReward to InlineTextContent(
|
||||
Placeholder(
|
||||
width = potentialLabelBgWidthSp,
|
||||
height = potentialStyle.lineHeight,
|
||||
placeholderVerticalAlign = PlaceholderVerticalAlign.Center,
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
text = potentialReward,
|
||||
style = potentialStyle,
|
||||
color = TangemTheme.colors3.text.primary,
|
||||
modifier = Modifier
|
||||
.background(
|
||||
color = TangemTheme.colors3.icon.brand,
|
||||
shape = RoundedCornerShape(6.dp),
|
||||
)
|
||||
.padding(horizontal = horizontalPadding),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// region Preview
|
||||
@Composable
|
||||
@Preview(showBackground = true, widthDp = 360)
|
||||
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
private fun ForYouEarnOpportunities_Preview(
|
||||
@PreviewParameter(ForYouEarnOpportunitiesPreviewProvider::class) params: EarnOpportunitiesUM,
|
||||
) {
|
||||
TangemThemePreviewRedesign {
|
||||
ForYouEarnOpportunities(
|
||||
earnOpportunitiesUM = params,
|
||||
modifier = Modifier.background(TangemTheme.colors3.bg.primary),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private class ForYouEarnOpportunitiesPreviewProvider : PreviewParameterProvider<EarnOpportunitiesUM> {
|
||||
override val values: Sequence<EarnOpportunitiesUM>
|
||||
get() = sequenceOf(
|
||||
ForYouEarnOpportunitiesPreviewData.tokensRewards,
|
||||
ForYouEarnOpportunitiesPreviewData.noAvailableTokens,
|
||||
ForYouEarnOpportunitiesPreviewData.allTokensActive,
|
||||
ForYouEarnOpportunitiesPreviewData.loading,
|
||||
)
|
||||
}
|
||||
// endregion
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<CryptoCurrency.Coin> {
|
||||
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 `"<networkRawId>_<contractAddress>"`. */
|
||||
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 `"<tokenId>-<networkRawId>"`. */
|
||||
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<YieldSupplyStatus> { every { isActive } returns active }
|
||||
}
|
||||
every { stakingBalance } returns if (isStakingActive) mockk<StakingBalance.Data.P2PEthPool>() 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<CryptoCurrencyStatus, EarnApyInfo> = 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<CryptoCurrencyStatus>,
|
||||
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()
|
||||
}
|
||||
|
|
@ -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<CryptoCurrency, StakingAvailability>(
|
||||
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<String, BigDecimal> = emptyMap(),
|
||||
yieldStakingAvailability: Map<CryptoCurrency, StakingAvailability> = 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) }),
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String> = emptySet(),
|
||||
expandClick: (String) -> Unit = {},
|
||||
) = ForYouEarnOpportunitiesPotentialRewardsConverter(
|
||||
appCurrency = appCurrency,
|
||||
isAccountsModeEnabled = isAccountsModeEnabled,
|
||||
expandedAssetIds = expandedAssetIds,
|
||||
expandClick = expandClick,
|
||||
)
|
||||
}
|
||||
|
|
@ -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 "+<fiat>/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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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) })
|
||||
}
|
||||
|
|
@ -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<ForYouTokenListItemUM>(),
|
||||
marketChartUM = MarketChartUM.NoData,
|
||||
),
|
||||
earnOpportunities = EarnOpportunitiesUM.Loading(tokenList = persistentListOf()),
|
||||
notifications = persistentListOf(),
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue