From eec91a9b4d2c6edebe4bce0af05884f3d53269af Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 15 Jul 2026 11:16:31 +0500 Subject: [PATCH] Updated on 2026-08-14 --- .../features/foryou/impl/entity/ForYouUM.kt | 20 ++ .../features/foryou/impl/model/ForYouModel.kt | 17 ++ .../converter/ForYouPortfolioFormatters.kt | 42 ---- .../converter/ForYouTokenListConverter.kt | 11 +- .../impl/model/converter/ForYouUtils.kt | 78 +++++++ .../ForYouEarnOpportunitiesConverter.kt | 213 ++++++++++++++++++ ...orYouEarnOpportunitiesNoTokensConverter.kt | 44 ++++ ...nOpportunitiesPotentialRewardsConverter.kt | 121 ++++++++++ ...orYouEarnOpportunitiesTokenRowConverter.kt | 135 +++++++++++ ...uEarnOpportunitiesTokensActiveConverter.kt | 45 ++++ ...ouEarnOpportunitiesTopTokenRowConverter.kt | 73 ++++++ .../features/foryou/impl/ui/ForYouContent.kt | 2 + .../foryou/impl/ui/ForYouEarnOpportunities.kt | 157 +++++++++++++ .../ForYouEarnOpportunitiesPreviewData.kt | 99 ++++++++ 14 files changed, 1010 insertions(+), 47 deletions(-) delete mode 100644 features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/ForYouPortfolioFormatters.kt create mode 100644 features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/ForYouUtils.kt create mode 100644 features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesConverter.kt create mode 100644 features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesNoTokensConverter.kt create mode 100644 features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesPotentialRewardsConverter.kt create mode 100644 features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTokenRowConverter.kt create mode 100644 features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTokensActiveConverter.kt create mode 100644 features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTopTokenRowConverter.kt create mode 100644 features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/ForYouEarnOpportunities.kt create mode 100644 features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/preview/ForYouEarnOpportunitiesPreviewData.kt diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/entity/ForYouUM.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/entity/ForYouUM.kt index d1832b2bcb..564a3fa05a 100644 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/entity/ForYouUM.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/entity/ForYouUM.kt @@ -1,15 +1,18 @@ package com.tangem.features.foryou.impl.entity +import androidx.annotation.StringRes import androidx.compose.runtime.Immutable import com.tangem.core.ui.ds.row.token.TangemTokenRowUM import com.tangem.core.ui.ds.tabs.TangemSegmentUM import com.tangem.core.ui.ds.tabs.TangemSegmentedPickerUM +import com.tangem.core.ui.extensions.TextReference import com.tangem.features.foryou.impl.components.state.MarketChartUM import com.tangem.features.foryou.impl.model.ForYouNotification import kotlinx.collections.immutable.ImmutableList internal data class ForYouUM( val portfolioReviewUM: PortfolioReviewUM, + val earnOpportunities: EarnOpportunitiesUM, val notifications: ImmutableList, ) @@ -31,6 +34,23 @@ internal sealed interface PortfolioReviewUM { ) : PortfolioReviewUM } +@Immutable +internal sealed interface EarnOpportunitiesUM { + + val tokenList: ImmutableList + + data class Loading( + override val tokenList: ImmutableList, + ) : EarnOpportunitiesUM + + data class Content( + override val tokenList: ImmutableList, + @param:StringRes val subtitleRes: Int, + val potentialReward: TextReference?, + val potentialRewardType: TextReference?, + ) : EarnOpportunitiesUM +} + @Immutable internal data class ForYouTokenListItemUM( val tokenRowUM: TangemTokenRowUM, diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/ForYouModel.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/ForYouModel.kt index 9116c76ea9..8164f5d156 100644 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/ForYouModel.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/ForYouModel.kt @@ -15,6 +15,7 @@ import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWalletId import com.tangem.features.foryou.ForYouComponent import com.tangem.features.foryou.impl.components.state.MarketChartUM +import com.tangem.features.foryou.impl.entity.EarnOpportunitiesUM import com.tangem.features.foryou.impl.entity.ForYouTokenListItemUM import com.tangem.features.foryou.impl.entity.ForYouUM import com.tangem.features.foryou.impl.entity.PortfolioReviewUM @@ -45,6 +46,22 @@ internal class ForYouModel @Inject constructor( field = MutableStateFlow( ForYouUM( notifications = persistentListOf(), + earnOpportunities = EarnOpportunitiesUM.Loading( + tokenList = buildList { + repeat(4) { index -> + add( + ForYouTokenListItemUM( + tokenRowUM = TangemTokenRowUM.Loading( + id = index.toString(), + ), + tokenList = persistentListOf(), + isExpanded = false, + isExpandable = false, + ), + ) + } + }.toPersistentList(), + ), portfolioReviewUM = PortfolioReviewUM.Loading( marketChartUM = MarketChartUM.NoData, tokenList = buildList { diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/ForYouPortfolioFormatters.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/ForYouPortfolioFormatters.kt deleted file mode 100644 index 5596541fff..0000000000 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/ForYouPortfolioFormatters.kt +++ /dev/null @@ -1,42 +0,0 @@ -package com.tangem.features.foryou.impl.model.converter - -import com.tangem.core.ui.ds.badge.TangemBadgeColor -import com.tangem.core.ui.ds.badge.TangemBadgeSize -import com.tangem.core.ui.ds.badge.TangemBadgeType -import com.tangem.core.ui.ds.badge.TangemBadgeUM -import com.tangem.core.ui.extensions.stringReference -import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.utils.extensions.isZero -import java.math.BigDecimal -import java.math.RoundingMode - -/** - * Formatting helpers shared by the For You portfolio-review converters. - * - * Kept null-safe so that non-[CryptoCurrencyStatus.Loaded] states (which carry no fiat amount) degrade - * to `null` instead of throwing. - */ - -/** - * Cross-network grouping key for the portfolio review: the same asset on different networks (e.g. USDC - * on Solana and Ethereum) shares its `rawCurrencyId`, so they group under a single item. Custom tokens - * have no raw id and fall back to their unique currency id, staying in their own group. - */ -internal fun CryptoCurrencyStatus.forYouGroupKey(): String = currency.id.rawCurrencyId?.value ?: currency.id.value - -/** - * Computes this fiat amount as a share of [totalFiatBalance]. Returns `null` when the share cannot be - * computed (no amount, or a zero total / amount). - */ -internal fun BigDecimal?.toForYouPercent(totalFiatBalance: BigDecimal): BigDecimal? { - if (this == null || totalFiatBalance.isZero() || isZero()) return null - return divide(totalFiatBalance, RoundingMode.HALF_UP) -} - -// TODO For You: replace this placeholder with the real price-change badge once the design is wired. -internal fun forYouPlaceholderBadge(): TangemBadgeUM = TangemBadgeUM( - text = stringReference("Positive"), - size = TangemBadgeSize.X4, - type = TangemBadgeType.Tinted, - color = TangemBadgeColor.Green, -) \ No newline at end of file diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/ForYouTokenListConverter.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/ForYouTokenListConverter.kt index dabad495fa..1b9f893650 100644 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/ForYouTokenListConverter.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/ForYouTokenListConverter.kt @@ -4,10 +4,7 @@ import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToI import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.ds.image.TangemIconUM import com.tangem.core.ui.ds.row.token.TangemTokenRowUM -import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.pluralReference -import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.* import com.tangem.core.ui.format.bigdecimal.fiat import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.format.bigdecimal.percent @@ -131,7 +128,11 @@ internal class ForYouTokenListConverter( headIconUM = TangemIconUM.Currency(CurrencyIconState.Empty()), titleUM = TangemTokenRowUM.TitleUM.Content(text = resourceReference(R.string.common_other)), subtitleUM = TangemTokenRowUM.SubtitleUM.Content( - text = pluralReference(R.plurals.market_chart_assets_android, otherAssets.count()), + text = pluralReference( + id = R.plurals.market_chart_assets_android, + count = otherAssets.count(), + formatArgs = wrappedList(otherAssets.count()), + ), ), topEndContentUM = TangemTokenRowUM.EndContentUM.Content( text = stringReference( diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/ForYouUtils.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/ForYouUtils.kt new file mode 100644 index 0000000000..6b2425900a --- /dev/null +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/ForYouUtils.kt @@ -0,0 +1,78 @@ +package com.tangem.features.foryou.impl.model.converter + +import com.tangem.core.ui.ds.badge.TangemBadgeColor +import com.tangem.core.ui.ds.badge.TangemBadgeSize +import com.tangem.core.ui.ds.badge.TangemBadgeType +import com.tangem.core.ui.ds.badge.TangemBadgeUM +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.utils.extensions.isZero +import java.math.BigDecimal +import java.math.RoundingMode + +/** Number of suggested top-earn tokens shown in the earn-opportunities block. */ +internal const val FOR_YOU_TOP_EARN_TOKENS_COUNT = 5 + +/** Batch size for getting top earn tokens in single and only page */ +internal const val TOP_EARN_TOKENS_BATCH_SIZE = 30 + +/** Divisor converting backend percent values (5.5) to fractions (0.055). */ +internal val PERCENT_BASE = BigDecimal("100") + +/** + * Cross-network grouping key for the portfolio review: the same asset on different networks (e.g. USDC + * on Solana and Ethereum) shares its `rawCurrencyId`, so they group under a single item. Custom tokens + * have no raw id and fall back to their unique currency id, staying in their own group. + */ +internal fun CryptoCurrencyStatus.forYouGroupKey(): String = currency.id.rawCurrencyId?.value ?: currency.id.value + +/** + * Matching key between a portfolio currency and a top-earn suggestion: the same asset + * (`rawCurrencyId`, falling back to the unique id for custom tokens) on the same network. + */ +internal fun CryptoCurrency.forYouEarnAssetKey(): Pair = + (id.rawCurrencyId?.value ?: id.value) to network.rawId + +/** + * Computes this fiat amount as a share of [totalFiatBalance]. Returns `null` when the share cannot be + * computed (no amount, or a zero total / amount). + */ +internal fun BigDecimal?.toForYouPercent(totalFiatBalance: BigDecimal): BigDecimal? { + if (this == null || totalFiatBalance.isZero() || isZero()) return null + return divide(totalFiatBalance, RoundingMode.HALF_UP) +} + +// TODO For You: replace this placeholder with the real price-change badge once the design is wired. +internal fun forYouPlaceholderBadge(): TangemBadgeUM = TangemBadgeUM( + text = stringReference("Positive"), + size = TangemBadgeSize.X4, + type = TangemBadgeType.Tinted, + color = TangemBadgeColor.Green, +) + +/** + * Earn rate resolved for a portfolio currency. + * + * @property isActive whether the user already earns on the token (active yield supply or stake) + * @property apy rate as a fraction (0.05 = 5%) + * @property potentialRewards projected yearly reward in fiat (`fiatAmount * apy`), `null` when unknown + */ +internal data class EarnApyInfo( + val isActive: Boolean, + val apy: BigDecimal?, + val potentialRewards: BigDecimal?, +) + +/** + * Earn-eligible currencies of one account with their resolved rates. + * + * @property accountPotentialReward sum of [EarnApyInfo.potentialRewards] over [earnCurrencues]; + * accounts are ordered by it, descending + */ +internal data class EarnOpportunities( + val account: Account.CryptoPortfolio, + val earnCurrencues: Map, + val accountPotentialReward: BigDecimal, +) \ No newline at end of file diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesConverter.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesConverter.kt new file mode 100644 index 0000000000..a95b2a2f69 --- /dev/null +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesConverter.kt @@ -0,0 +1,213 @@ +package com.tangem.features.foryou.impl.model.converter.earnOpportunities + +import com.tangem.domain.account.models.AccountStatusList +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.account.filterCryptoPortfolio +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.currency.yieldSupplyKey +import com.tangem.domain.models.earn.EarnTopToken +import com.tangem.domain.models.staking.StakingBalance +import com.tangem.domain.staking.model.StakingAvailability +import com.tangem.domain.staking.model.StakingOption +import com.tangem.domain.staking.model.common.RewardInfo +import com.tangem.domain.staking.model.common.RewardType +import com.tangem.domain.staking.model.optionOrNull +import com.tangem.features.foryou.impl.entity.EarnOpportunitiesUM +import com.tangem.features.foryou.impl.model.converter.EarnApyInfo +import com.tangem.features.foryou.impl.model.converter.EarnOpportunities +import com.tangem.features.foryou.impl.model.converter.PERCENT_BASE +import com.tangem.lib.crypto.BlockchainUtils +import com.tangem.utils.converter.Converter +import com.tangem.utils.extensions.orZero +import com.tangem.utils.isNullOrZero +import java.math.BigDecimal +import java.math.RoundingMode + +/** + * Builds the For You earn-opportunities section. For every portfolio currency it resolves an earn rate + * (see [resolveEarnApy]) and keeps only tokens that could earn (positive fiat balance) or already do + * (active yield supply / stake). + * + * The section state is then picked from the result: + * - nothing is earn-eligible → [ForYouEarnOpportunitiesNoTokensConverter] (suggests [topEarnTokens]); + * - every eligible token already earns → [ForYouEarnOpportunitiesTokensActiveConverter] + * (suggests [topEarnTokens] the user is not earning on yet); + * - otherwise → [ForYouEarnOpportunitiesPotentialRewardsConverter] (per-account potential rewards, + * accounts sorted by reward descending). + */ +@Suppress("LongParameterList") +internal class ForYouEarnOpportunitiesConverter( + private val appCurrency: AppCurrency, + private val isAccountsModeEnabled: Boolean, + private val expandedAssetIds: Set, + private val expandClick: (assetId: String) -> Unit, + private val yieldSupplyAvailability: Map, + private val yieldStakingAvailability: Map, + private val topEarnTokens: EarnTopToken?, +) : Converter { + + override fun convert(value: AccountStatusList?): EarnOpportunitiesUM { + val data = value?.accountStatuses + ?.filterCryptoPortfolio() + ?.asSequence() + ?.mapNotNull { cryptoAccountStatus -> + val tokenList = + cryptoAccountStatus.flattenCurrencies().mapNotNull { cryptoCurrencyStatus -> + val earn = resolveEarnApy( + cryptoCurrencyStatus = cryptoCurrencyStatus, + yieldModuleApyMap = yieldSupplyAvailability, + stakingApyMap = yieldStakingAvailability, + ) + + if (earn == null || cryptoCurrencyStatus.value.fiatAmount.isNullOrZero() && !earn.isActive) { + return@mapNotNull null + } + + cryptoCurrencyStatus to earn + } + + if (tokenList.isEmpty()) return@mapNotNull null + + val accountPotentialReward = tokenList.sumOf { (_, earn) -> + earn.potentialRewards.orZero() + } + + EarnOpportunities( + account = cryptoAccountStatus.account, + earnCurrencues = tokenList.toMap(), + accountPotentialReward = accountPotentialReward, + ) + } + ?.sortedByDescending { it.accountPotentialReward } + .orEmpty().toList() + + return when { + data.isEmpty() -> { + ForYouEarnOpportunitiesNoTokensConverter(topEarnTokens).convert(data) + } + data.all { earn -> earn.earnCurrencues.all { entry -> entry.value.isActive } } -> { + ForYouEarnOpportunitiesTokensActiveConverter(topEarnTokens).convert(data) + } + else -> { + ForYouEarnOpportunitiesPotentialRewardsConverter( + appCurrency = appCurrency, + isAccountsModeEnabled = isAccountsModeEnabled, + expandedAssetIds = expandedAssetIds, + expandClick = expandClick, + ).convert(data) + } + } + } + + /** + * Resolves the earn rate for a currency; yield supply takes precedence over staking. The returned + * [EarnApyInfo.apy] is a fraction (0.05 = 5%): yield APY arrives from the backend in percent and is + * scaled down by [PERCENT_BASE]. Returns `null` when the token cannot earn at all. + */ + private fun resolveEarnApy( + cryptoCurrencyStatus: CryptoCurrencyStatus, + yieldModuleApyMap: Map, + stakingApyMap: Map, + ): EarnApyInfo? { + val token = cryptoCurrencyStatus.currency as? CryptoCurrency.Token + if (token != null && yieldModuleApyMap.isNotEmpty()) { + val yieldSupplyApy = yieldModuleApyMap.entries.firstOrNull { (tokenId, _) -> + tokenId.equals( + other = token.yieldSupplyKey(), + ignoreCase = BlockchainUtils.isCaseInsensitiveContractAddress(token.network.rawId), + ) + }?.value + if (yieldSupplyApy != null) { + val isActive = cryptoCurrencyStatus.value.yieldSupplyStatus?.isActive == true + val apy = yieldSupplyApy.divide(PERCENT_BASE, RoundingMode.HALF_UP) + return EarnApyInfo( + isActive = isActive, + potentialRewards = cryptoCurrencyStatus.value.fiatAmount?.multiply(apy), + apy = apy, + ) + } + } + + if (stakingApyMap.isNotEmpty()) { + val stakingInfo = findStakingRate( + currencyStatus = cryptoCurrencyStatus, + stakingApyMap = stakingApyMap, + ) + if (stakingInfo.rate != null) { + return EarnApyInfo( + isActive = stakingInfo.isActive, + apy = stakingInfo.rate, + potentialRewards = cryptoCurrencyStatus.value.fiatAmount?.multiply(stakingInfo.rate), + ) + } + } + + return null + } + + /** + * Picks the staking rate to display: for an active StakeKit stake — the rate of the validator the user + * actually stakes with (falling back to the best preferred one), otherwise the best preferred + * validator's rate. [StakingAvailability.Full] pools are surfaced only for tokens already staked. + */ + private fun findStakingRate( + currencyStatus: CryptoCurrencyStatus, + stakingApyMap: Map, + ): StakingLocalInfo { + val availability = stakingApyMap[currencyStatus.currency] + val option = availability?.optionOrNull + ?: return StakingLocalInfo(rate = null, isActive = false, rewardType = null) + + val stakingBalance = currencyStatus.value.stakingBalance as? StakingBalance.Data + val stakeKitBalance = stakingBalance as? StakingBalance.Data.StakeKit + val p2pEthPoolBalance = stakingBalance as? StakingBalance.Data.P2PEthPool + val isActive = stakeKitBalance != null || p2pEthPoolBalance != null + + // Full = no free capacity: show the badge only for tokens that already have a stake. + if (availability is StakingAvailability.Full && !isActive) { + return StakingLocalInfo(rate = null, isActive = false, rewardType = null) + } + + val rateInfo = when (option) { + is StakingOption.P2PEthPool -> { + RewardInfo( + rate = option.apy, + type = RewardType.APY, + ) + } + is StakingOption.StakeKit -> if (stakeKitBalance != null) { + val validatorsByAddress = option.yield.validators.associateBy { it.address } + stakeKitBalance.balance.items + .mapNotNull { it.validatorAddress } + .firstNotNullOfOrNull { address -> + validatorsByAddress[address]?.rewardInfo + } ?: option.yield.validators + .filter { it.preferred } + .mapNotNull { validator -> + validator.rewardInfo + } + .maxByOrNull { it.rate } + } else { + option.yield.validators + .filter { it.preferred } + .mapNotNull { validator -> + validator.rewardInfo + } + .maxByOrNull { it.rate } + } + } + + return StakingLocalInfo( + rate = rateInfo?.rate, + isActive = isActive, + rewardType = rateInfo?.type, + ) + } + + private data class StakingLocalInfo( + val rate: BigDecimal?, + val isActive: Boolean, + val rewardType: RewardType?, + ) +} \ No newline at end of file diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesNoTokensConverter.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesNoTokensConverter.kt new file mode 100644 index 0000000000..2f363e16d9 --- /dev/null +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesNoTokensConverter.kt @@ -0,0 +1,44 @@ +package com.tangem.features.foryou.impl.model.converter.earnOpportunities + +import com.tangem.common.ui.R +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.format.bigdecimal.percent +import com.tangem.core.ui.utils.parseBigDecimalOrNull +import com.tangem.domain.models.earn.EarnTopToken +import com.tangem.features.foryou.impl.entity.EarnOpportunitiesUM +import com.tangem.features.foryou.impl.model.converter.EarnOpportunities +import com.tangem.features.foryou.impl.model.converter.FOR_YOU_TOP_EARN_TOKENS_COUNT +import com.tangem.utils.converter.Converter +import kotlinx.collections.immutable.toPersistentList + +/** + * Earn-opportunities state for a portfolio with nothing earn-eligible: suggests the top + * [FOR_YOU_TOP_EARN_TOKENS_COUNT] earn tokens, headed by the best (first) suggestion's yearly rate + * ([EarnOpportunitiesUM.Content.potentialReward]) and its reward type, APR/APY + * ([EarnOpportunitiesUM.Content.potentialRewardType]). + */ +internal class ForYouEarnOpportunitiesNoTokensConverter( + private val topEarnTokens: EarnTopToken?, +) : Converter, EarnOpportunitiesUM> { + + override fun convert(value: List): EarnOpportunitiesUM { + val topEarnTokenList = topEarnTokens?.getOrNull() + ?.take(FOR_YOU_TOP_EARN_TOKENS_COUNT) + + val topEarnToken = topEarnTokenList?.firstOrNull()?.earnToken + val topEarnApy = topEarnToken?.apy?.parseBigDecimalOrNull() + + val rowConverter = ForYouEarnOpportunitiesTopTokenRowConverter() + + return EarnOpportunitiesUM.Content( + tokenList = topEarnTokenList + ?.map(rowConverter::convert) + .orEmpty() + .toPersistentList(), + subtitleRes = R.string.for_you_earn_opportunities_no_available_tokens, + potentialReward = stringReference(topEarnApy.format { percent() }), + potentialRewardType = topEarnToken?.rewardType?.name?.let(::stringReference), + ) + } +} \ No newline at end of file diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesPotentialRewardsConverter.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesPotentialRewardsConverter.kt new file mode 100644 index 0000000000..47d65c42b3 --- /dev/null +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesPotentialRewardsConverter.kt @@ -0,0 +1,121 @@ +package com.tangem.features.foryou.impl.model.converter.earnOpportunities + +import com.tangem.common.ui.R +import com.tangem.common.ui.account.AccountIconItemStateConverter +import com.tangem.common.ui.account.toUM +import com.tangem.core.ui.components.account.AccountIconSize +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.ds.row.token.TangemTokenRowUM +import com.tangem.core.ui.extensions.* +import com.tangem.core.ui.format.bigdecimal.fiat +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.account.Account +import com.tangem.features.foryou.impl.entity.EarnOpportunitiesUM +import com.tangem.features.foryou.impl.entity.ForYouTokenListItemUM +import com.tangem.features.foryou.impl.model.converter.EarnOpportunities +import com.tangem.utils.StringsSigns +import com.tangem.utils.converter.Converter +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList +import java.math.BigDecimal + +internal class ForYouEarnOpportunitiesPotentialRewardsConverter( + private val appCurrency: AppCurrency, + private val isAccountsModeEnabled: Boolean, + private val expandedAssetIds: Set, + private val expandClick: (assetId: String) -> Unit, +) : Converter, EarnOpportunitiesUM> { + + private val rowConverter = ForYouEarnOpportunitiesTokenRowConverter(appCurrency = appCurrency) + + override fun convert(value: List): EarnOpportunitiesUM { + val totalPotentialReward = value.sumOf { it.accountPotentialReward } + val totalPotentialRewardText = resourceReference( + R.string.for_you_earn_per_year, + wrappedList( + totalPotentialReward.format { + fiat( + fiatCurrencySymbol = appCurrency.symbol, + fiatCurrencyCode = appCurrency.code, + ) + }, + ), + ) + + return EarnOpportunitiesUM.Content( + tokenList = value.flatMap { earnData -> + if (isAccountsModeEnabled) { + listOf( + ForYouTokenListItemUM( + tokenRowUM = createAssetRow( + account = earnData.account, + potentialReward = earnData.accountPotentialReward, + tokenCount = earnData.earnCurrencues.size, + ), + tokenList = rowConverter.convertList(earnData.earnCurrencues.toList()) + .toPersistentList(), + isExpanded = earnData.account.accountId.value in expandedAssetIds, + isExpandable = true, + ), + ) + } else { + earnData.earnCurrencues.map { token -> + ForYouTokenListItemUM( + tokenRowUM = rowConverter.convert(token.toPair()), + tokenList = persistentListOf(), + isExpanded = false, + isExpandable = false, + ) + } + } + }.toPersistentList(), + subtitleRes = R.string.for_you_earn_opportunities_tokens_rewards, + potentialReward = totalPotentialRewardText, + potentialRewardType = null, + ) + } + + private fun createAssetRow( + account: Account.CryptoPortfolio, + potentialReward: BigDecimal?, + tokenCount: Int, + ): TangemTokenRowUM { + return TangemTokenRowUM.Content( + id = account.accountId.value, + headIconUM = TangemIconUM.Currency( + currencyIconState = AccountIconItemStateConverter(size = AccountIconSize.RedesignedDefault) + .convert(account), + ), + titleUM = TangemTokenRowUM.TitleUM.Content( + text = account.accountName.toUM().value, + ), + subtitleUM = TangemTokenRowUM.SubtitleUM.Content( + text = pluralReference( + R.plurals.common_tokens_count, + count = tokenCount, + formatArgs = wrappedList(tokenCount), + ), + ), + topEndContentUM = TangemTokenRowUM.EndContentUM.Content( + text = combinedReference( + stringReference(StringsSigns.PLUS), + resourceReference( + R.string.for_you_earn_per_year, + wrappedList( + potentialReward.format { + fiat( + fiatCurrencySymbol = appCurrency.symbol, + fiatCurrencyCode = appCurrency.code, + ) + }, + ), + ), + ), + ), + bottomEndContentUM = TangemTokenRowUM.EndContentUM.Empty, + onItemClick = { expandClick(account.accountId.value) }, + onItemLongClick = null, + ) + } +} \ No newline at end of file diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTokenRowConverter.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTokenRowConverter.kt new file mode 100644 index 0000000000..7e9b237516 --- /dev/null +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTokenRowConverter.kt @@ -0,0 +1,135 @@ +package com.tangem.features.foryou.impl.model.converter.earnOpportunities + +import androidx.compose.ui.text.SpanStyle +import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.common.ui.tokens.TokenItemStateConverter.Companion.isFlickering +import com.tangem.core.ui.R +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.ds.row.token.TangemTokenRowUM +import com.tangem.core.ui.ds.row.token.TangemTokenRowUM.SubtitleUM.Content +import com.tangem.core.ui.extensions.* +import com.tangem.core.ui.format.bigdecimal.fiat +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.format.bigdecimal.percent +import com.tangem.core.ui.res.TangemTheme +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.StatusSource +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.features.foryou.impl.model.converter.EarnApyInfo +import com.tangem.utils.StringsSigns +import com.tangem.utils.converter.Converter +import com.tangem.utils.extensions.orZero +import kotlinx.collections.immutable.toImmutableList +import java.math.BigDecimal + +internal class ForYouEarnOpportunitiesTokenRowConverter( + private val appCurrency: AppCurrency, +) : Converter, TangemTokenRowUM> { + + private val iconConverter = CryptoCurrencyToIconStateConverter() + + override fun convert(value: Pair): TangemTokenRowUM { + val (cryptoCurrencyStatus, earnApyInfo) = value + if (cryptoCurrencyStatus.value is CryptoCurrencyStatus.Loading) { + return TangemTokenRowUM.Loading(id = cryptoCurrencyStatus.currency.id.value) + } + + val possibleEarnAmount = cryptoCurrencyStatus.value.fiatAmount.orZero().multiply(earnApyInfo.apy.orZero()) + + return TangemTokenRowUM.Content( + id = cryptoCurrencyStatus.currency.id.value, + headIconUM = TangemIconUM.Currency(iconConverter.convert(cryptoCurrencyStatus)), + titleUM = TangemTokenRowUM.TitleUM.Content(text = stringReference(cryptoCurrencyStatus.currency.name)), + subtitleUM = Content( + text = resourceReference( + R.string.wallet_network_group_title, + wrappedList(cryptoCurrencyStatus.currency.network.name), + ), + ), + topEndContentUM = toRowTopEnd(cryptoCurrencyStatus, possibleEarnAmount), + bottomEndContentUM = toRowBottomEnd(cryptoCurrencyStatus, earnApyInfo.apy.orZero()), + onItemClick = null, + onItemLongClick = null, + ) + } + + /** Top-end: fiat total for resolved states, dash / unreachable treatment otherwise. */ + private fun toRowTopEnd( + cryptoCurrencyStatus: CryptoCurrencyStatus, + possibleEarnAmount: BigDecimal, + ): TangemTokenRowUM.EndContentUM = when (cryptoCurrencyStatus.value) { + CryptoCurrencyStatus.Loading -> TangemTokenRowUM.EndContentUM.Loading + is CryptoCurrencyStatus.Custom, + is CryptoCurrencyStatus.Loaded, + is CryptoCurrencyStatus.NoAccount, + -> { + val possibleEarn = possibleEarnAmount.format { + fiat( + fiatCurrencyCode = appCurrency.code, + fiatCurrencySymbol = appCurrency.symbol, + ) + } + + TangemTokenRowUM.EndContentUM.Content( + text = combinedReference( + stringReference(StringsSigns.PLUS), + resourceReference( + R.string.for_you_earn_per_year, + wrappedList(possibleEarn), + ), + ), + isFlickering = cryptoCurrencyStatus.value.isFlickering(), + startIcons = buildList { + if (cryptoCurrencyStatus.value.sources.total == StatusSource.ONLY_CACHE) { + add( + TangemIconUM.Icon( + iconRes = R.drawable.ic_error_sync_default_24, + tintReference = { TangemTheme.colors3.icon.tertiary }, + ), + ) + } + }.toImmutableList(), + ) + } + is CryptoCurrencyStatus.NoQuote, + is CryptoCurrencyStatus.MissedDerivation, + is CryptoCurrencyStatus.NoAmount, + is CryptoCurrencyStatus.Unreachable, + -> TangemTokenRowUM.EndContentUM.Content(text = stringReference(StringsSigns.DASH_SIGN)) + } + + /** Bottom-end: percentage share for resolved states, no-address / unreachable treatment otherwise. */ + private fun toRowBottomEnd( + cryptoCurrencyStatus: CryptoCurrencyStatus, + earnRate: BigDecimal, + ): TangemTokenRowUM.EndContentUM = when (cryptoCurrencyStatus.value) { + CryptoCurrencyStatus.Loading -> TangemTokenRowUM.EndContentUM.Loading + is CryptoCurrencyStatus.Custom, + is CryptoCurrencyStatus.Loaded, + is CryptoCurrencyStatus.NoAccount, + -> { + TangemTokenRowUM.EndContentUM.Content( + text = styledStringReference( + value = earnRate.format { percent() }, + spanStyleReference = { SpanStyle(color = TangemTheme.colors3.text.status.success) }, + ), + isFlickering = cryptoCurrencyStatus.value.isFlickering(), + startIcons = buildList { + if (cryptoCurrencyStatus.value.sources.total == StatusSource.ONLY_CACHE) { + add( + TangemIconUM.Icon( + iconRes = R.drawable.ic_error_sync_default_24, + tintReference = { TangemTheme.colors3.icon.tertiary }, + ), + ) + } + }.toImmutableList(), + ) + } + is CryptoCurrencyStatus.NoQuote, + is CryptoCurrencyStatus.MissedDerivation, + is CryptoCurrencyStatus.NoAmount, + is CryptoCurrencyStatus.Unreachable, + -> TangemTokenRowUM.EndContentUM.Content(text = stringReference(StringsSigns.DASH_SIGN)) + } +} \ No newline at end of file diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTokensActiveConverter.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTokensActiveConverter.kt new file mode 100644 index 0000000000..dbe05ab54a --- /dev/null +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTokensActiveConverter.kt @@ -0,0 +1,45 @@ +package com.tangem.features.foryou.impl.model.converter.earnOpportunities + +import com.tangem.common.ui.R +import com.tangem.domain.models.earn.EarnTopToken +import com.tangem.features.foryou.impl.entity.EarnOpportunitiesUM +import com.tangem.features.foryou.impl.model.converter.EarnOpportunities +import com.tangem.features.foryou.impl.model.converter.FOR_YOU_TOP_EARN_TOKENS_COUNT +import com.tangem.features.foryou.impl.model.converter.forYouEarnAssetKey +import com.tangem.utils.converter.Converter +import kotlinx.collections.immutable.toPersistentList + +/** + * Earn-opportunities state for a portfolio where every earn-eligible token is already active: + * suggests the top earn tokens the user is not earning on yet (the ones already active are filtered out). + * + * Matching is per asset **and** network (see [forYouEarnAssetKey]), so an asset active on one network + * can still be suggested on another. Active tokens are filtered out before the + * [FOR_YOU_TOP_EARN_TOKENS_COUNT] cap, so exclusions don't shrink the suggestion list while more + * candidates remain in the batch. + */ +internal class ForYouEarnOpportunitiesTokensActiveConverter( + private val topEarnTokens: EarnTopToken?, +) : Converter, EarnOpportunitiesUM> { + + override fun convert(value: List): EarnOpportunitiesUM { + val activeAssetKeys = value + .flatMap { opportunities -> opportunities.earnCurrencues.keys } + .map { status -> status.currency.forYouEarnAssetKey() } + .toSet() + + val rowConverter = ForYouEarnOpportunitiesTopTokenRowConverter() + + return EarnOpportunitiesUM.Content( + tokenList = topEarnTokens?.getOrNull() + ?.filterNot { topToken -> topToken.cryptoCurrency.forYouEarnAssetKey() in activeAssetKeys } + ?.take(FOR_YOU_TOP_EARN_TOKENS_COUNT) + ?.map(rowConverter::convert) + .orEmpty() + .toPersistentList(), + subtitleRes = R.string.for_you_earn_opportunities_all_tokens_active, + potentialReward = null, + potentialRewardType = null, + ) + } +} \ No newline at end of file diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTopTokenRowConverter.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTopTokenRowConverter.kt new file mode 100644 index 0000000000..c8db01a057 --- /dev/null +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTopTokenRowConverter.kt @@ -0,0 +1,73 @@ +package com.tangem.features.foryou.impl.model.converter.earnOpportunities + +import com.tangem.common.ui.R +import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.ds.row.token.TangemTokenRowUM +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.format.bigdecimal.percent +import com.tangem.domain.models.earn.EarnTokenWithCurrency +import com.tangem.domain.models.earn.EarnType +import com.tangem.features.foryou.impl.entity.ForYouTokenListItemUM +import com.tangem.utils.converter.Converter +import kotlinx.collections.immutable.persistentListOf +import java.math.BigDecimal + +/** + * Maps a top-earn token (an opportunity the user doesn't hold yet) to a For You list item: + * network subtitle, APY top-end and earn-type bottom-end. Shared by the earn-opportunities + * converters that surface suggestions ([ForYouEarnOpportunitiesNoTokensConverter], + * [ForYouEarnOpportunitiesTokensActiveConverter]). + */ +internal class ForYouEarnOpportunitiesTopTokenRowConverter : Converter { + + private val iconConverter = CryptoCurrencyToIconStateConverter() + + override fun convert(value: EarnTokenWithCurrency): ForYouTokenListItemUM { + val (networkName, earnToken, cryptoCurrency) = value + + return ForYouTokenListItemUM( + tokenRowUM = TangemTokenRowUM.Content( + id = cryptoCurrency.id.value, + headIconUM = TangemIconUM.Currency(iconConverter.convert(cryptoCurrency)), + titleUM = TangemTokenRowUM.TitleUM.Content( + text = stringReference(cryptoCurrency.name), + ), + subtitleUM = TangemTokenRowUM.SubtitleUM.Content( + text = resourceReference( + R.string.wallet_network_group_title, + wrappedList(networkName), + ), + ), + topEndContentUM = TangemTokenRowUM.EndContentUM.Content( + text = resourceReference( + R.string.markets_apy_placeholder, + wrappedList(convertPercent(earnToken.apy)), + ), + ), + bottomEndContentUM = TangemTokenRowUM.EndContentUM.Content( + text = resourceReference( + when (earnToken.type) { + EarnType.STAKING -> R.string.common_staking + EarnType.YIELD -> R.string.common_yield_mode + }, + ), + ), + onItemClick = {}, + onItemLongClick = { _, _ -> }, + ), + tokenList = persistentListOf(), + isExpanded = false, + isExpandable = false, + ) + } + + private fun convertPercent(value: String): TextReference { + val percent = BigDecimal(value).format { percent(withPercentSign = false) } + return TextReference.Str(percent) + } +} \ No newline at end of file diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/ForYouContent.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/ForYouContent.kt index b4ae2bcbfd..7314fb2afc 100644 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/ForYouContent.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/ForYouContent.kt @@ -26,6 +26,7 @@ import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreviewRedesign import com.tangem.features.foryou.impl.entity.ForYouUM import com.tangem.features.foryou.impl.model.ForYouNotification +import com.tangem.features.foryou.impl.ui.preview.ForYouEarnOpportunitiesPreviewData import com.tangem.features.foryou.impl.ui.preview.ForYouPortfolioReviewPreviewData import com.tangem.features.promobanners.api.PromoBannersBlockComponent import kotlinx.collections.immutable.persistentListOf @@ -111,6 +112,7 @@ private class ForYouContentPreviewProvider : PreviewParameterProvider get() = sequenceOf( ForYouUM( notifications = persistentListOf(ForYouNotification.UsedOutdatedData), + earnOpportunities = ForYouEarnOpportunitiesPreviewData.tokensRewards, portfolioReviewUM = ForYouPortfolioReviewPreviewData.reviewContent, ), ) diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/ForYouEarnOpportunities.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/ForYouEarnOpportunities.kt new file mode 100644 index 0000000000..cb513e9ac9 --- /dev/null +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/ForYouEarnOpportunities.kt @@ -0,0 +1,157 @@ +package com.tangem.features.foryou.impl.ui + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.foundation.text.appendInlineContent +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.ds2.shimmers.TangemShimmer +import com.tangem.core.ui.extensions.* +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.features.foryou.impl.R +import com.tangem.features.foryou.impl.entity.EarnOpportunitiesUM +import com.tangem.features.foryou.impl.ui.components.ForYouPortfolioTokenList +import com.tangem.features.foryou.impl.ui.preview.ForYouEarnOpportunitiesPreviewData + +private const val INLINE_CONTENT_PADDING_COEF = 2.2f + +@Composable +internal fun ForYouEarnOpportunities(earnOpportunitiesUM: EarnOpportunitiesUM, modifier: Modifier = Modifier) { + Column( + modifier = modifier, + ) { + Text( + text = stringResourceSafe(R.string.for_you_earn_opportunities), + style = TangemTheme.typography3.heading.small, + color = TangemTheme.colors3.text.secondary, + ) + + when (earnOpportunitiesUM) { + is EarnOpportunitiesUM.Content -> { + val (textContent, inlineContent) = if (earnOpportunitiesUM.potentialReward != null) { + val potentialReward = earnOpportunitiesUM.potentialReward.resolveReference() + + val inlineContent = rememberEarnInlineContent(potentialReward) + + val textContent = resourceReference( + earnOpportunitiesUM.subtitleRes, + wrappedList( + annotatedReference { + if (earnOpportunitiesUM.potentialRewardType != null) { + append(earnOpportunitiesUM.potentialRewardType.resolveAnnotatedReference()) + appendSpace() + } + appendInlineContent(potentialReward, potentialReward) + }, + ), + ) + + textContent to inlineContent + } else { + resourceReference(earnOpportunitiesUM.subtitleRes) to emptyMap() + } + + Text( + text = textContent.resolveAnnotatedReference(), + inlineContent = inlineContent, + style = TangemTheme.typography3.heading.small, + color = TangemTheme.colors3.text.primary, + ) + } + is EarnOpportunitiesUM.Loading -> { + TangemShimmer( + style = TangemTheme.typography3.heading.small, + ) + } + } + + ForYouPortfolioTokenList( + tokenList = earnOpportunitiesUM.tokenList, + modifier = Modifier.padding(top = 8.dp), + ) + } +} + +@Composable +private fun rememberEarnInlineContent(potentialReward: String): Map { + val density = LocalDensity.current + val textMeasurer = rememberTextMeasurer() + val horizontalPadding = 2.dp + + val potentialStyle = TangemTheme.typography3.heading.small + + val potentialLabelBgWidthSp = remember(potentialReward) { + val textLayoutMeasure = textMeasurer.measure( + text = potentialReward, + style = potentialStyle, + ) + with(density) { + (textLayoutMeasure.size.width.toDp() + (horizontalPadding.value * INLINE_CONTENT_PADDING_COEF).dp).toSp() + } + } + + return remember(potentialReward, potentialLabelBgWidthSp) { + mapOf( + potentialReward to InlineTextContent( + Placeholder( + width = potentialLabelBgWidthSp, + height = potentialStyle.lineHeight, + placeholderVerticalAlign = PlaceholderVerticalAlign.Center, + ), + ) { + Text( + text = potentialReward, + style = potentialStyle, + color = TangemTheme.colors3.text.primary, + modifier = Modifier + .background( + color = TangemTheme.colors3.icon.brand, + shape = RoundedCornerShape(6.dp), + ) + .padding(horizontal = horizontalPadding), + ) + }, + ) + } +} + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun ForYouEarnOpportunities_Preview( + @PreviewParameter(ForYouEarnOpportunitiesPreviewProvider::class) params: EarnOpportunitiesUM, +) { + TangemThemePreviewRedesign { + ForYouEarnOpportunities( + earnOpportunitiesUM = params, + modifier = Modifier.background(TangemTheme.colors3.bg.primary), + ) + } +} + +private class ForYouEarnOpportunitiesPreviewProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + ForYouEarnOpportunitiesPreviewData.tokensRewards, + ForYouEarnOpportunitiesPreviewData.noAvailableTokens, + ForYouEarnOpportunitiesPreviewData.allTokensActive, + ForYouEarnOpportunitiesPreviewData.loading, + ) +} +// endregion \ No newline at end of file diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/preview/ForYouEarnOpportunitiesPreviewData.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/preview/ForYouEarnOpportunitiesPreviewData.kt new file mode 100644 index 0000000000..400a57c04b --- /dev/null +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/preview/ForYouEarnOpportunitiesPreviewData.kt @@ -0,0 +1,99 @@ +package com.tangem.features.foryou.impl.ui.preview + +import com.tangem.core.ui.R +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.ds.row.token.TangemTokenRowUM +import com.tangem.core.ui.extensions.stringReference +import com.tangem.features.foryou.impl.entity.EarnOpportunitiesUM +import com.tangem.features.foryou.impl.entity.ForYouTokenListItemUM +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList + +internal object ForYouEarnOpportunitiesPreviewData { + + private const val TOTAL_POTENTIAL_REWARD = "\$32.14/year" + private const val TOP_EARN_APY = "4.5%/year" + + /** Tokens with earn opportunities: subtitle with the inline reward badge + flat token rows. */ + val tokensRewards = EarnOpportunitiesUM.Content( + subtitleRes = R.string.for_you_earn_opportunities_tokens_rewards, + potentialReward = stringReference(TOTAL_POTENTIAL_REWARD), + potentialRewardType = null, + tokenList = persistentListOf( + earnTokenRow( + id = "token_0", + name = "Ethereum", + network = "Ethereum network", + topEnd = "+ \$24.16/year", + bottomEnd = "4,5%", + ), + earnTokenRow( + id = "token_1", + name = "Solana", + network = "Solana network", + topEnd = "+ \$7.98/year", + bottomEnd = "6,2%", + ), + ), + ) + + /** No earnable tokens in the portfolio: top earn tokens teaser. */ + val noAvailableTokens = EarnOpportunitiesUM.Content( + subtitleRes = R.string.for_you_earn_opportunities_no_available_tokens, + potentialReward = stringReference(TOP_EARN_APY), + potentialRewardType = stringReference("APY"), + tokenList = persistentListOf( + earnTokenRow( + id = "top_token_0", + name = "TON", + network = "TON network", + topEnd = "APY 4,5", + bottomEnd = "Staking", + ), + ), + ) + + /** Every earnable token is already earning: plain subtitle, no reward badge, no rows. */ + val allTokensActive = EarnOpportunitiesUM.Content( + subtitleRes = R.string.for_you_earn_opportunities_all_tokens_active, + potentialReward = null, + potentialRewardType = null, + tokenList = persistentListOf(), + ) + + val loading = EarnOpportunitiesUM.Loading( + tokenList = List(size = 4) { index -> + ForYouTokenListItemUM( + tokenRowUM = TangemTokenRowUM.Loading(id = index.toString()), + tokenList = persistentListOf(), + isExpanded = false, + isExpandable = false, + ) + }.toPersistentList(), + ) + + private fun earnTokenRow( + id: String, + name: String, + network: String, + topEnd: String, + bottomEnd: String, + ): ForYouTokenListItemUM { + return ForYouTokenListItemUM( + tokenRowUM = TangemTokenRowUM.Content( + id = id, + headIconUM = TangemIconUM.Currency(CurrencyIconState.Loading), + titleUM = TangemTokenRowUM.TitleUM.Content(text = stringReference(name)), + subtitleUM = TangemTokenRowUM.SubtitleUM.Content(text = stringReference(network)), + topEndContentUM = TangemTokenRowUM.EndContentUM.Content(text = stringReference(topEnd)), + bottomEndContentUM = TangemTokenRowUM.EndContentUM.Content(text = stringReference(bottomEnd)), + onItemClick = null, + onItemLongClick = null, + ), + tokenList = persistentListOf(), + isExpanded = false, + isExpandable = false, + ) + } +} \ No newline at end of file