Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-15 11:02:27 +05:00
parent 7e51309355
commit f86441df6a
15 changed files with 343 additions and 160 deletions

View file

@ -39,6 +39,8 @@ dependencies {
api(projects.domain.appCurrency)
api(projects.domain.common)
api(projects.domain.wallets)
api(projects.domain.earn)
api(projects.domain.yieldSupply)
implementation(projects.domain.account)
implementation(projects.domain.models)

View file

@ -14,6 +14,8 @@ internal data class ForYouUM(
val portfolioReviewUM: PortfolioReviewUM,
val earnOpportunities: EarnOpportunitiesUM,
val notifications: ImmutableList<ForYouNotification>,
val periodPickerUM: TangemSegmentedPickerUM,
val onPeriodClick: (tangemSegmentUM: TangemSegmentUM) -> Unit,
)
@Immutable
@ -29,20 +31,32 @@ internal sealed interface PortfolioReviewUM {
data class Content(
override val tokenList: ImmutableList<ForYouTokenListItemUM>,
override val marketChartUM: MarketChartUM,
val periodPickerUM: TangemSegmentedPickerUM,
val onPeriodClick: (TangemSegmentUM) -> Unit,
) : PortfolioReviewUM
}
/**
* State of the earn-opportunities section. [tokenList] holds either the user's earn-eligible holdings
* or top-earn suggestions, depending on which content state was picked (see
* `ForYouEarnOpportunitiesConverter` for the selection rules).
*/
@Immutable
internal sealed interface EarnOpportunitiesUM {
val tokenList: ImmutableList<ForYouTokenListItemUM>
/** Skeleton rows shown until the first real emission. */
data class Loading(
override val tokenList: ImmutableList<ForYouTokenListItemUM>,
) : EarnOpportunitiesUM
/**
* @property subtitleRes section subtitle matching the picked state (nothing eligible / all active /
* potential rewards)
* @property potentialReward header value: the total projected yearly reward for eligible holdings,
* or the best suggestion's rate when the user holds nothing eligible; `null` when not applicable
* @property potentialRewardType label of [potentialReward]'s rate kind (APR/APY); only set alongside
* a rate-based reward
*/
data class Content(
override val tokenList: ImmutableList<ForYouTokenListItemUM>,
@param:StringRes val subtitleRes: Int,

View file

@ -2,25 +2,39 @@ package com.tangem.features.foryou.impl.model
import androidx.compose.runtime.Stable
import arrow.core.getOrElse
import arrow.core.left
import arrow.core.right
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
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.domain.account.status.supplier.MultiAccountStatusListSupplier
import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.earn.EarnErrorResolver
import com.tangem.domain.earn.model.EarnTokensBatchingContext
import com.tangem.domain.earn.model.EarnTokensListConfig
import com.tangem.domain.earn.usecase.GetEarnTokensBatchFlowUseCase
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.earn.EarnTopToken
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.staking.usecase.StakingAvailabilityListUseCase
import com.tangem.domain.yield.supply.usecase.YieldSupplyApyFlowUseCase
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
import com.tangem.features.foryou.impl.entity.*
import com.tangem.features.foryou.impl.model.converter.TOP_EARN_TOKENS_BATCH_SIZE
import com.tangem.features.foryou.impl.model.converter.earnOpportunities.ForYouEarnOpportunitiesConverter
import com.tangem.features.foryou.impl.model.converter.portfolioReview.ForYouPortfolioReviewConverter
import com.tangem.features.foryou.impl.model.transformer.SetPortfolioReviewTransformer
import com.tangem.pagination.BatchAction
import com.tangem.pagination.PaginationStatus
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.combine6
import com.tangem.utils.transformer.update
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
@ -29,26 +43,34 @@ import javax.inject.Inject
@Stable
@ModelScoped
@Suppress("LongParameterList")
internal class ForYouModel @Inject constructor(
paramsContainer: ParamsContainer,
userWalletsListRepository: UserWalletsListRepository,
multiAccountStatusListSupplier: MultiAccountStatusListSupplier,
yieldSupplyApyFlowUseCase: YieldSupplyApyFlowUseCase,
override val dispatchers: CoroutineDispatcherProvider,
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val getEarnTokensBatchFlowUseCase: GetEarnTokensBatchFlowUseCase,
private val stakingAvailabilityListUseCase: StakingAvailabilityListUseCase,
private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase,
private val earnErrorResolver: EarnErrorResolver,
) : Model() {
private val params = paramsContainer.require<ForYouComponent.Params>()
private val expandedAssetIds = MutableStateFlow<Set<String>>(value = emptySet())
private val expandedPortfolioReviewAssetIds = MutableStateFlow<Set<String>>(value = emptySet())
private val expandedEarnOpportunitiesAssetIds = MutableStateFlow<Set<String>>(value = emptySet())
private val selectedAppCurrencyFlow: StateFlow<AppCurrency> = createSelectedAppCurrencyFlow()
val uiState: StateFlow<ForYouUM>
field = MutableStateFlow<ForYouUM>(
ForYouUM(
notifications = persistentListOf(),
periodPickerUM = TangemSegmentedPickerUM(persistentListOf()),
earnOpportunities = EarnOpportunitiesUM.Loading(
tokenList = buildList<ForYouTokenListItemUM> {
repeat(4) { index ->
repeat(5) { index ->
add(
ForYouTokenListItemUM(
tokenRowUM = TangemTokenRowUM.Loading(
@ -62,6 +84,7 @@ internal class ForYouModel @Inject constructor(
}
}.toPersistentList(),
),
onPeriodClick = ::onPeriodClick,
portfolioReviewUM = PortfolioReviewUM.Loading(
marketChartUM = MarketChartUM.NoData,
tokenList = buildList<ForYouTokenListItemUM> {
@ -83,21 +106,51 @@ internal class ForYouModel @Inject constructor(
)
init {
combine(
flow = userWalletsListRepository.selectedUserWallet,
combine6(
flow1 = userWalletsListRepository.selectedUserWallet,
flow2 = multiAccountStatusListSupplier.invokeAsMap(),
flow3 = expandedAssetIds,
) { globalSelectedWallet, accountStatusList, expanded ->
flow3 = expandedPortfolioReviewAssetIds,
flow4 = expandedEarnOpportunitiesAssetIds,
flow5 = yieldSupplyApyFlowUseCase(),
flow6 = createTopEarnTokensFlow(),
) {
globalSelectedWallet, accountList,
expandedPortfolioReview, expandedEarnOpportunities,
yieldAvailability, topEarnTokens,
->
val stakingAvailability = accountList.flatMap { (userWalletId, accountStatusList) ->
stakingAvailabilityListUseCase.invokeSync(
userWalletId = userWalletId,
cryptoCurrencyList = accountStatusList.flattenCurrencies().map { it.currency },
).entries
}.associate { it.key to it.value }
// TODO For You add choose portfolio flow
val selectedWalletId = globalSelectedWallet?.walletId
val accountStatusList = accountList[globalSelectedWallet?.walletId]
val portfolioReviewUM = ForYouPortfolioReviewConverter(
appCurrency = selectedAppCurrencyFlow.value,
expandedAssetIds = expandedPortfolioReview,
expandClick = ::onExpandPortfolioReviewClick,
onTokenClick = ::onPortfolioReviewTokenClick,
).convert(accountStatusList)
val earnOpportunitiesUM = ForYouEarnOpportunitiesConverter(
appCurrency = selectedAppCurrencyFlow.value,
isAccountsModeEnabled = isAccountsModeEnabledUseCase.invokeSync(),
yieldSupplyAvailability = yieldAvailability,
yieldStakingAvailability = stakingAvailability,
topEarnTokens = topEarnTokens,
expandedAssetIds = expandedEarnOpportunities,
expandClick = ::onExpandEarnOpportunitiesClick,
).convert(accountStatusList)
uiState.update(
SetPortfolioReviewTransformer(
accountStatusList = accountStatusList[selectedWalletId],
appCurrency = selectedAppCurrencyFlow.value,
expandedAssetIds = expanded,
expandClick = ::onExpandClick,
onPeriodClick = ::onPeriodClick,
onTokenClick = { currency -> onTokenClick(selectedWalletId, currency) },
accountStatusList = accountStatusList,
portfolioReviewUM = portfolioReviewUM,
earnOpportunitiesUM = earnOpportunitiesUM,
),
)
}
@ -105,6 +158,40 @@ internal class ForYouModel @Inject constructor(
.launchIn(modelScope)
}
/**
* Fetches the top-earn suggestions as a single batch of [TOP_EARN_TOKENS_BATCH_SIZE] tokens (a
* one-shot [BatchAction.Reload]; no further paging). Emits `null` while the initial load is in
* flight, a resolved error on failure, and the flattened token list on success so the earn
* section can distinguish "not loaded yet" from "loaded empty".
*/
private fun createTopEarnTokensFlow(): Flow<EarnTopToken?> {
val actionsFlow = MutableSharedFlow<BatchAction<Int, EarnTokensListConfig, Nothing>>(replay = 1)
val batchFlow = getEarnTokensBatchFlowUseCase(
context = EarnTokensBatchingContext(
actionsFlow = actionsFlow,
coroutineScope = modelScope,
),
batchSize = TOP_EARN_TOKENS_BATCH_SIZE,
)
actionsFlow.tryEmit(
BatchAction.Reload(
requestParams = EarnTokensListConfig(type = null, networks = null, isForEarn = false),
),
)
return batchFlow.state.map { state ->
when (val status = state.status) {
is PaginationStatus.None,
is PaginationStatus.InitialLoading,
-> null
is PaginationStatus.InitialLoadingError -> earnErrorResolver.resolve(status.throwable).left()
else -> state.data.flatMap { batch -> batch.data }.right()
}
}
}
private fun createSelectedAppCurrencyFlow(): StateFlow<AppCurrency> {
return getSelectedAppCurrencyUseCase().map { maybeAppCurrency ->
maybeAppCurrency.getOrElse { AppCurrency.Default }
@ -115,13 +202,19 @@ internal class ForYouModel @Inject constructor(
)
}
private fun onTokenClick(selectedWalletId: UserWalletId?, currency: CryptoCurrency) {
private fun onPortfolioReviewTokenClick(selectedWalletId: UserWalletId?, currency: CryptoCurrency) {
val walletId = selectedWalletId ?: return
params.callbacks.onTokenClick(walletId, currency)
}
private fun onExpandClick(assetId: String) {
expandedAssetIds.update { ids ->
private fun onExpandPortfolioReviewClick(assetId: String) {
expandedPortfolioReviewAssetIds.update { ids ->
if (assetId in ids) ids - assetId else ids + assetId
}
}
private fun onExpandEarnOpportunitiesClick(assetId: String) {
expandedEarnOpportunitiesAssetIds.update { ids ->
if (assetId in ids) ids - assetId else ids + assetId
}
}
@ -129,11 +222,9 @@ internal class ForYouModel @Inject constructor(
private fun onPeriodClick(tangemSegmentUM: TangemSegmentUM) {
uiState.update { state ->
state.copy(
portfolioReviewUM = (state.portfolioReviewUM as? PortfolioReviewUM.Content)?.copy(
periodPickerUM = state.portfolioReviewUM.periodPickerUM.copy(
initialSelectedItem = tangemSegmentUM,
),
) ?: state.portfolioReviewUM,
periodPickerUM = state.periodPickerUM.copy(
initialSelectedItem = tangemSegmentUM,
),
)
}
}

View file

@ -15,7 +15,11 @@ 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 */
/**
* Batch size for the top-earn-tokens request. Only the first batch is ever fetched (the section shows
* at most [FOR_YOU_TOP_EARN_TOKENS_COUNT] rows), but it is requested larger so that filtering out
* already-active tokens still leaves enough candidates to fill the list.
*/
internal const val TOP_EARN_TOKENS_BATCH_SIZE = 30
/** Divisor converting backend percent values (5.5) to fractions (0.055). */
@ -68,11 +72,11 @@ internal data class EarnApyInfo(
/**
* Earn-eligible currencies of one account with their resolved rates.
*
* @property accountPotentialReward sum of [EarnApyInfo.potentialRewards] over [earnCurrencues];
* @property accountPotentialReward sum of [EarnApyInfo.potentialRewards] over [earnCurrencies];
* accounts are ordered by it, descending
*/
internal data class EarnOpportunities(
val account: Account.CryptoPortfolio,
val earnCurrencues: Map<CryptoCurrencyStatus, EarnApyInfo>,
val earnCurrencies: Map<CryptoCurrencyStatus, EarnApyInfo>,
val accountPotentialReward: BigDecimal,
)

View file

@ -75,7 +75,7 @@ internal class ForYouEarnOpportunitiesConverter(
EarnOpportunities(
account = cryptoAccountStatus.account,
earnCurrencues = tokenList.toMap(),
earnCurrencies = tokenList.toMap(),
accountPotentialReward = accountPotentialReward,
)
}
@ -84,10 +84,14 @@ internal class ForYouEarnOpportunitiesConverter(
return when {
data.isEmpty() -> {
ForYouEarnOpportunitiesNoTokensConverter(topEarnTokens).convert(data)
ForYouEarnOpportunitiesNoTokensConverter(
topEarnTokens = topEarnTokens,
).convert(data)
}
data.all { earn -> earn.earnCurrencues.all { entry -> entry.value.isActive } } -> {
ForYouEarnOpportunitiesTokensActiveConverter(topEarnTokens).convert(data)
data.all { earn -> earn.earnCurrencies.all { entry -> entry.value.isActive } } -> {
ForYouEarnOpportunitiesTokensActiveConverter(
topEarnTokens = topEarnTokens,
).convert(data)
}
else -> {
ForYouEarnOpportunitiesPotentialRewardsConverter(
@ -134,7 +138,7 @@ internal class ForYouEarnOpportunitiesConverter(
currencyStatus = cryptoCurrencyStatus,
stakingApyMap = stakingApyMap,
)
if (stakingInfo.rate != null) {
if (stakingInfo != null) {
return EarnApyInfo(
isActive = stakingInfo.isActive,
apy = stakingInfo.rate,
@ -154,10 +158,9 @@ internal class ForYouEarnOpportunitiesConverter(
private fun findStakingRate(
currencyStatus: CryptoCurrencyStatus,
stakingApyMap: Map<CryptoCurrency, StakingAvailability>,
): StakingLocalInfo {
): StakingLocalInfo? {
val availability = stakingApyMap[currencyStatus.currency]
val option = availability?.optionOrNull
?: return StakingLocalInfo(rate = null, isActive = false, rewardType = null)
val option = availability?.optionOrNull ?: return null
val stakingBalance = currencyStatus.value.stakingBalance as? StakingBalance.Data
val stakeKitBalance = stakingBalance as? StakingBalance.Data.StakeKit
@ -166,7 +169,7 @@ internal class ForYouEarnOpportunitiesConverter(
// 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)
return null
}
val rateInfo = when (option) {
@ -196,18 +199,18 @@ internal class ForYouEarnOpportunitiesConverter(
}
.maxByOrNull { it.rate }
}
}
} ?: return null
return StakingLocalInfo(
rate = rateInfo?.rate,
rate = rateInfo.rate,
isActive = isActive,
rewardType = rateInfo?.type,
rewardType = rateInfo.type,
)
}
private data class StakingLocalInfo(
val rate: BigDecimal?,
val rate: BigDecimal,
val isActive: Boolean,
val rewardType: RewardType?,
val rewardType: RewardType,
)
}

View file

@ -20,6 +20,15 @@ import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import java.math.BigDecimal
/**
* Earn-opportunities state for a portfolio with tokens that could earn but don't yet: renders the
* user's earn-eligible holdings with their projected yearly rewards, headed by the total across
* accounts ([EarnOpportunitiesUM.Content.potentialReward]).
*
* With accounts mode on, each account becomes one expandable row (children delegated to
* [ForYouEarnOpportunitiesTokenRowConverter], expansion keyed by account id); with it off, the
* tokens are rendered as flat non-expandable rows.
*/
internal class ForYouEarnOpportunitiesPotentialRewardsConverter(
private val appCurrency: AppCurrency,
private val isAccountsModeEnabled: Boolean,
@ -51,16 +60,16 @@ internal class ForYouEarnOpportunitiesPotentialRewardsConverter(
tokenRowUM = createAssetRow(
account = earnData.account,
potentialReward = earnData.accountPotentialReward,
tokenCount = earnData.earnCurrencues.size,
tokenCount = earnData.earnCurrencies.size,
),
tokenList = rowConverter.convertList(earnData.earnCurrencues.toList())
tokenList = rowConverter.convertList(earnData.earnCurrencies.toList())
.toPersistentList(),
isExpanded = earnData.account.accountId.value in expandedAssetIds,
isExpandable = true,
),
)
} else {
earnData.earnCurrencues.map { token ->
earnData.earnCurrencies.map { token ->
ForYouTokenListItemUM(
tokenRowUM = rowConverter.convert(token.toPair()),
tokenList = persistentListOf(),

View file

@ -22,6 +22,13 @@ import com.tangem.utils.extensions.orZero
import kotlinx.collections.immutable.toImmutableList
import java.math.BigDecimal
/**
* Maps one earn-eligible portfolio holding to a token row: network subtitle, projected yearly earn
* (`fiat balance * rate`) as the top end and the rate itself as the styled bottom end.
*
* Non-resolved statuses degrade the same way as in the portfolio review: loading skeleton row,
* no-quote / no-address / unreachable dashes, stale cache error-sync icon on both ends.
*/
internal class ForYouEarnOpportunitiesTokenRowConverter(
private val appCurrency: AppCurrency,
) : Converter<Pair<CryptoCurrencyStatus, EarnApyInfo>, TangemTokenRowUM> {

View file

@ -24,7 +24,7 @@ internal class ForYouEarnOpportunitiesTokensActiveConverter(
override fun convert(value: List<EarnOpportunities>): EarnOpportunitiesUM {
val activeAssetKeys = value
.flatMap { opportunities -> opportunities.earnCurrencues.keys }
.flatMap { opportunities -> opportunities.earnCurrencies.keys }
.map { status -> status.currency.forYouEarnAssetKey() }
.toSet()

View file

@ -1,4 +1,4 @@
package com.tangem.features.foryou.impl.model.converter
package com.tangem.features.foryou.impl.model.converter.portfolioReview
import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
@ -8,14 +8,21 @@ 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.domain.account.models.AccountStatusList
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.TotalFiatBalance
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.foryou.impl.R
import com.tangem.features.foryou.impl.entity.ForYouTokenListItemUM
import com.tangem.features.foryou.impl.entity.PortfolioReviewUM
import com.tangem.features.foryou.impl.model.converter.forYouGroupKey
import com.tangem.features.foryou.impl.model.converter.forYouPlaceholderBadge
import com.tangem.features.foryou.impl.model.converter.toForYouPercent
import com.tangem.utils.converter.Converter
import com.tangem.utils.extensions.isZero
import com.tangem.utils.extensions.orZero
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import java.math.BigDecimal
@ -25,41 +32,78 @@ import java.math.BigDecimal
* (see [forYouGroupKey]) and maps each group to a [ForYouTokenListItemUM] an aggregate asset row plus,
* when the asset spans more than one network, its per-network child rows.
*
* The child rows are grouped by network (delegated to [ForYouTokenRowConverter]) so a network appears
* The child rows are grouped by network (delegated to [ForYouPortfolioReviewTokenRowConverter]) so a network appears
* once per asset even if the asset is held on it in several accounts;
*
* Modelled on `TokenListStateConverter` (a list converter delegating to a per-item converter).
*/
internal class ForYouTokenListConverter(
internal class ForYouPortfolioReviewConverter(
private val appCurrency: AppCurrency,
private val totalFiatBalance: BigDecimal,
private val expandedAssetIds: Set<String>,
private val expandClick: (assetId: String) -> Unit,
private val otherAssets: List<Pair<List<CryptoCurrencyStatus>, BigDecimal>>,
private val onTokenClick: (CryptoCurrency) -> Unit,
) : Converter<List<CryptoCurrencyStatus>, ImmutableList<ForYouTokenListItemUM>> {
private val onTokenClick: (UserWalletId, CryptoCurrency) -> Unit,
) : Converter<AccountStatusList?, PortfolioReviewUM> {
private val iconConverter = CryptoCurrencyToIconStateConverter()
private val rowConverter = ForYouTokenRowConverter(
appCurrency = appCurrency,
totalFiatBalance = totalFiatBalance,
onTokenClick = onTokenClick,
)
override fun convert(value: List<CryptoCurrencyStatus>): ImmutableList<ForYouTokenListItemUM> {
val assetItems = value
override fun convert(value: AccountStatusList?): PortfolioReviewUM {
val currencies = value?.flattenCurrencies().orEmpty()
val loadedBalance = value?.totalFiatBalance as? TotalFiatBalance.Loaded
val totalFiatBalance = loadedBalance?.amount.orZero()
// Drop only assets we positively know are empty — a resolved, priced zero fiat balance. Currencies
// whose fiat we couldn't determine (unreachable / no-address / no-quote / still-loading — i.e. any
// non-content status, which all carry a null fiatAmount) are kept so the converter can still render
// them with the appropriate treatment instead of hiding a token the user actually holds.
// Then aggregate the rest into assets (the same token across networks shares its forYouGroupKey)
// and rank assets by their *summed* fiat balance.
val rankedAssets = currencies
.filterNot { it.value.fiatAmount?.isZero() == true }
.groupBy { it.forYouGroupKey() }
.map { (assetId, currencies) -> createListItem(assetId, currencies) }
.map { (_, networks) -> networks to networks.sumOf { it.value.fiatAmount.orZero() } }
.sortedByDescending { (_, assetBalance) -> assetBalance }
// The top assets are shown individually (each flattened back to its networks so the converter can
// regroup them by network); the remaining assets are collapsed into a single "Other" row.
val topAssets = rankedAssets.take(TOP_HOLDINGS_COUNT)
val otherAssets = rankedAssets.drop(TOP_HOLDINGS_COUNT)
val topCurrencies = topAssets.flatMap { (networks, _) -> networks }
val assetItems = topCurrencies
.groupBy { it.forYouGroupKey() }
.map { (assetId, currencies) ->
createListItem(
userWalletId = value?.userWalletId,
assetId = assetId,
currencies = currencies,
totalFiatBalance = totalFiatBalance,
)
}
// Assets beyond the top ones are collapsed into a single non-expandable "Other" row at the bottom.
return if (otherAssets.count() > 0) {
assetItems + createOtherItem()
val tokenList = if (otherAssets.count() > 0) {
assetItems + createOtherItem(otherAssets, totalFiatBalance)
} else {
assetItems
}.toPersistentList()
val marketChartUM = ForYouPortfolioReviewMarketChartConverter(
appCurrency = appCurrency,
topAssets = topAssets,
).convert(value?.totalFiatBalance)
return PortfolioReviewUM.Content(
tokenList = tokenList,
marketChartUM = marketChartUM,
)
}
private fun createListItem(assetId: String, currencies: List<CryptoCurrencyStatus>): ForYouTokenListItemUM {
private fun createListItem(
userWalletId: UserWalletId?,
assetId: String,
currencies: List<CryptoCurrencyStatus>,
totalFiatBalance: BigDecimal,
): ForYouTokenListItemUM {
// Group the asset's holdings by blockchain (network.id.rawId, derivation-independent) so each
// network appears once even when the asset is held across several accounts/derivations on it,
// summing those balances. Order by balance so the expanded breakdown reads top-down.
@ -67,11 +111,21 @@ internal class ForYouTokenListConverter(
.groupBy { it.currency.network.id.rawId }
.values
.sortedByDescending { group -> group.sumOf { it.value.fiatAmount.orZero() } }
val rowConverter = ForYouPortfolioReviewTokenRowConverter(
userWalletId = userWalletId,
appCurrency = appCurrency,
totalFiatBalance = totalFiatBalance,
onTokenClick = onTokenClick,
)
return ForYouTokenListItemUM(
tokenRowUM = createAssetRow(
userWalletId = userWalletId,
assetId = assetId,
currencies = currencies,
networkCount = networkGroups.size,
totalFiatBalance = totalFiatBalance,
),
tokenList = networkGroups.map(rowConverter::convertNetworkGroup).toPersistentList(),
isExpanded = assetId in expandedAssetIds,
@ -81,8 +135,10 @@ internal class ForYouTokenListConverter(
private fun createAssetRow(
assetId: String,
userWalletId: UserWalletId?,
currencies: List<CryptoCurrencyStatus>,
networkCount: Int,
totalFiatBalance: BigDecimal,
): TangemTokenRowUM {
if (currencies.all { it.value is CryptoCurrencyStatus.Loading }) {
return TangemTokenRowUM.Loading(id = assetId)
@ -91,6 +147,12 @@ internal class ForYouTokenListConverter(
val asset = currencies.first()
val assetFiatBalance = currencies.sumOf { it.value.fiatAmount.orZero() }
val rowConverter = ForYouPortfolioReviewTokenRowConverter(
userWalletId = userWalletId,
appCurrency = appCurrency,
totalFiatBalance = totalFiatBalance,
onTokenClick = onTokenClick,
)
val endContent = rowConverter.toEndContent(statuses = currencies, fiatAmount = assetFiatBalance)
val onlyCryptoCurrency = currencies.firstOrNull()?.currency
@ -120,7 +182,10 @@ internal class ForYouTokenListConverter(
)
}
private fun createOtherItem(): ForYouTokenListItemUM {
private fun createOtherItem(
otherAssets: List<Pair<List<CryptoCurrencyStatus>, BigDecimal>>,
totalFiatBalance: BigDecimal,
): ForYouTokenListItemUM {
val otherAssetsBalance = otherAssets.sumOf { (_, assetBalance) -> assetBalance }
return ForYouTokenListItemUM(
tokenRowUM = TangemTokenRowUM.Content(
@ -158,5 +223,6 @@ internal class ForYouTokenListConverter(
private companion object {
const val OTHER_ROW_ID = "for_you_other_assets"
const val TOP_HOLDINGS_COUNT = 4
}
}

View file

@ -1,4 +1,4 @@
package com.tangem.features.foryou.impl.model.converter
package com.tangem.features.foryou.impl.model.converter.portfolioReview
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.format.bigdecimal.fiat
@ -7,13 +7,18 @@ import com.tangem.core.ui.format.bigdecimal.percent
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.TotalFiatBalance
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.features.foryou.impl.components.state.*
import com.tangem.features.foryou.impl.components.state.AiInsightUM
import com.tangem.features.foryou.impl.components.state.DonutChartUM
import com.tangem.features.foryou.impl.components.state.DonutSegmentColor
import com.tangem.features.foryou.impl.components.state.DonutSegmentUM
import com.tangem.features.foryou.impl.components.state.MarketChartUM
import com.tangem.features.foryou.impl.model.converter.toForYouPercent
import com.tangem.utils.converter.Converter
import com.tangem.utils.extensions.orZero
import kotlinx.collections.immutable.toPersistentList
import java.math.BigDecimal
internal class ForYouMarketChartConverter(
internal class ForYouPortfolioReviewMarketChartConverter(
private val appCurrency: AppCurrency,
private val topAssets: List<Pair<List<CryptoCurrencyStatus>, BigDecimal>>,
) : Converter<TotalFiatBalance?, MarketChartUM> {

View file

@ -1,4 +1,4 @@
package com.tangem.features.foryou.impl.model.converter
package com.tangem.features.foryou.impl.model.converter.portfolioReview
import androidx.compose.ui.text.SpanStyle
import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
@ -16,6 +16,9 @@ import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.foryou.impl.model.converter.forYouPlaceholderBadge
import com.tangem.features.foryou.impl.model.converter.toForYouPercent
import com.tangem.utils.StringsSigns
import com.tangem.utils.extensions.orZero
import kotlinx.collections.immutable.persistentListOf
@ -39,10 +42,11 @@ import java.math.BigDecimal
* classification (they contribute nothing yet). The cache/flicker indicators derive from the most
* conservative [CryptoCurrencyStatus.Sources.total] across the contributing statuses.
*/
internal class ForYouTokenRowConverter(
internal class ForYouPortfolioReviewTokenRowConverter(
private val appCurrency: AppCurrency,
private val userWalletId: UserWalletId?,
private val totalFiatBalance: BigDecimal,
private val onTokenClick: (CryptoCurrency) -> Unit,
private val onTokenClick: (UserWalletId, CryptoCurrency) -> Unit,
) {
private val iconConverter = CryptoCurrencyToIconStateConverter()
@ -66,7 +70,7 @@ internal class ForYouTokenRowConverter(
subtitleUM = toRowSubtitle(state, currency, cryptoAmount),
topEndContentUM = toRowTopEnd(state, fiatAmount),
bottomEndContentUM = toRowBottomEnd(state, fiatAmount),
onItemClick = { onTokenClick(currency) },
onItemClick = { if (userWalletId != null) onTokenClick(userWalletId, currency) },
onItemLongClick = null,
)
}
@ -88,9 +92,9 @@ internal class ForYouTokenRowConverter(
)
}
/** Title: For You always shows the asset symbol with the placeholder price-change badge. */
/** Title: For You always shows the asset name with the placeholder price-change badge. */
private fun toRowTitle(currency: CryptoCurrency): TangemTokenRowUM.TitleUM = TangemTokenRowUM.TitleUM.Content(
text = stringReference(currency.symbol),
text = stringReference(currency.name),
badge = forYouPlaceholderBadge(),
)

View file

@ -4,94 +4,48 @@ import com.tangem.core.ui.ds.tabs.TangemSegmentUM
import com.tangem.core.ui.ds.tabs.TangemSegmentedPickerUM
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.account.models.AccountStatusList
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.TotalFiatBalance
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.features.foryou.impl.entity.EarnOpportunitiesUM
import com.tangem.features.foryou.impl.entity.ForYouUM
import com.tangem.features.foryou.impl.entity.PortfolioReviewUM
import com.tangem.features.foryou.impl.model.ForYouNotification
import com.tangem.features.foryou.impl.model.converter.ForYouMarketChartConverter
import com.tangem.features.foryou.impl.model.converter.ForYouTokenListConverter
import com.tangem.features.foryou.impl.model.converter.forYouGroupKey
import com.tangem.utils.extensions.isZero
import com.tangem.utils.extensions.orZero
import com.tangem.features.foryou.impl.model.converter.portfolioReview.ForYouPortfolioReviewConverter
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.persistentListOf
/**
* Builds the [ForYouUM] state for the For You screen: the outdated-data notifications plus the portfolio
* review (market chart, period picker and the grouped token list).
* Applies one combined emission to the [ForYouUM] state: sets the pre-built portfolio-review and
* earn-opportunities sections (see [ForYouPortfolioReviewConverter] and
* `ForYouEarnOpportunitiesConverter`) and derives the outdated-data notification from
* [accountStatusList]'s total-balance source.
*
* The token list is delegated to [ForYouTokenListConverter] and the market chart to
* [ForYouMarketChartConverter]; the period picker selection is carried over from the previous state so
* it is not reset on every balance refresh.
* subsequent refreshes the previous picker is carried over so the user's selection is not reset.
*
* Modelled on `SetTokenListTransformer` (a transformer that rebuilds the state while delegating the
* token-list construction to a dedicated converter).
* section construction to dedicated converters).
*/
@Suppress("LongParameterList")
internal class SetPortfolioReviewTransformer(
private val accountStatusList: AccountStatusList?,
private val appCurrency: AppCurrency,
private val expandedAssetIds: Set<String>,
private val expandClick: (assetId: String) -> Unit,
private val onPeriodClick: (TangemSegmentUM) -> Unit,
private val onTokenClick: (CryptoCurrency) -> Unit,
private val portfolioReviewUM: PortfolioReviewUM,
private val earnOpportunitiesUM: EarnOpportunitiesUM,
) : Transformer<ForYouUM> {
override fun transform(prevState: ForYouUM): ForYouUM {
val currencies = accountStatusList?.flattenCurrencies().orEmpty()
val loadedBalance = accountStatusList?.totalFiatBalance as? TotalFiatBalance.Loaded
val totalFiatBalance = loadedBalance?.amount.orZero()
// Drop only assets we positively know are empty — a resolved, priced zero fiat balance. Currencies
// whose fiat we couldn't determine (unreachable / no-address / no-quote / still-loading — i.e. any
// non-content status, which all carry a null fiatAmount) are kept so the converter can still render
// them with the appropriate treatment instead of hiding a token the user actually holds.
// Then aggregate the rest into assets (the same token across networks shares its forYouGroupKey)
// and rank assets by their *summed* fiat balance.
val rankedAssets = currencies
.filterNot { it.value.fiatAmount?.isZero() == true }
.groupBy { it.forYouGroupKey() }
.map { (_, networks) -> networks to networks.sumOf { it.value.fiatAmount.orZero() } }
.sortedByDescending { (_, assetBalance) -> assetBalance }
// The top assets are shown individually (each flattened back to its networks so the converter can
// regroup them by network); the remaining assets are collapsed into a single "Other" row.
val topAssets = rankedAssets.take(TOP_HOLDINGS_COUNT)
val otherAssets = rankedAssets.drop(TOP_HOLDINGS_COUNT)
val topCurrencies = topAssets.flatMap { (networks, _) -> networks }
val tokenList = ForYouTokenListConverter(
appCurrency = appCurrency,
totalFiatBalance = totalFiatBalance,
expandedAssetIds = expandedAssetIds,
expandClick = expandClick,
otherAssets = otherAssets,
onTokenClick = onTokenClick,
).convert(topCurrencies)
val marketChartUM = ForYouMarketChartConverter(
appCurrency = appCurrency,
topAssets = topAssets,
).convert(accountStatusList?.totalFiatBalance)
return prevState.copy(
notifications = if (loadedBalance?.source == StatusSource.ONLY_CACHE) {
persistentListOf(ForYouNotification.UsedOutdatedData)
} else {
persistentListOf()
},
portfolioReviewUM = PortfolioReviewUM.Content(
periodPickerUM = when (prevState.portfolioReviewUM) {
is PortfolioReviewUM.Content -> prevState.portfolioReviewUM.periodPickerUM
is PortfolioReviewUM.Loading -> createPeriodPicker()
},
tokenList = tokenList,
marketChartUM = marketChartUM,
onPeriodClick = onPeriodClick,
),
earnOpportunities = earnOpportunitiesUM,
portfolioReviewUM = portfolioReviewUM,
periodPickerUM = when (prevState.portfolioReviewUM) {
is PortfolioReviewUM.Loading -> createPeriodPicker()
is PortfolioReviewUM.Content -> prevState.periodPickerUM
},
)
}
@ -109,8 +63,4 @@ internal class SetPortfolioReviewTransformer(
isAltSurface = true,
)
}
private companion object {
const val TOP_HOLDINGS_COUNT = 4
}
}

View file

@ -19,8 +19,11 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEachIndexed
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
import com.tangem.core.ui.ds.tabs.TangemSegmentUM
import com.tangem.core.ui.ds.tabs.TangemSegmentedPickerUM
import com.tangem.core.ui.ds2.messagebanner.TangemMessageBanner
import com.tangem.core.ui.extensions.conditional
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.LocalMainBottomSheetColor
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
@ -78,6 +81,15 @@ internal fun ForYouContent(
ForYouPortfolioReview(
portfolioReviewUM = forYouUM.portfolioReviewUM,
periodPickerUM = forYouUM.periodPickerUM,
onPeriodClick = forYouUM.onPeriodClick,
modifier = Modifier.padding(horizontal = 16.dp),
)
SpacerH(48.dp)
ForYouEarnOpportunities(
earnOpportunitiesUM = forYouUM.earnOpportunities,
modifier = Modifier.padding(horizontal = 16.dp),
)
@ -114,6 +126,17 @@ private class ForYouContentPreviewProvider : PreviewParameterProvider<ForYouUM>
notifications = persistentListOf(ForYouNotification.UsedOutdatedData),
earnOpportunities = ForYouEarnOpportunitiesPreviewData.tokensRewards,
portfolioReviewUM = ForYouPortfolioReviewPreviewData.reviewContent,
periodPickerUM = TangemSegmentedPickerUM(
items = persistentListOf(
TangemSegmentUM(id = "0", title = stringReference("Day")),
TangemSegmentUM(id = "1", title = stringReference("Week")),
TangemSegmentUM(id = "2", title = stringReference("Month")),
),
initialSelectedItem = TangemSegmentUM(id = "0", title = stringReference("Day")),
isFixed = true,
isAltSurface = true,
),
onPeriodClick = {},
),
)
}

View file

@ -14,7 +14,9 @@ import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds.row.token.TangemTokenRowUM
import com.tangem.core.ui.ds.tabs.TangemSegmentUM
import com.tangem.core.ui.ds.tabs.TangemSegmentedPicker
import com.tangem.core.ui.ds.tabs.TangemSegmentedPickerUM
import com.tangem.core.ui.ds2.badge.TangemBadge
import com.tangem.core.ui.ds2.shimmers.TangemShimmer
import com.tangem.core.ui.extensions.stringReference
@ -34,7 +36,12 @@ import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
@Composable
internal fun ForYouPortfolioReview(portfolioReviewUM: PortfolioReviewUM, modifier: Modifier = Modifier) {
internal fun ForYouPortfolioReview(
periodPickerUM: TangemSegmentedPickerUM,
onPeriodClick: (TangemSegmentUM) -> Unit,
portfolioReviewUM: PortfolioReviewUM,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
Row(
modifier = Modifier.fillMaxWidth(),
@ -64,8 +71,8 @@ internal fun ForYouPortfolioReview(portfolioReviewUM: PortfolioReviewUM, modifie
when (portfolioReviewUM) {
is PortfolioReviewUM.Content -> {
TangemSegmentedPicker(
tangemSegmentedPickerUM = portfolioReviewUM.periodPickerUM,
onClick = portfolioReviewUM.onPeriodClick,
tangemSegmentedPickerUM = periodPickerUM,
onClick = onPeriodClick,
)
}
is PortfolioReviewUM.Loading -> TangemShimmer(
@ -91,6 +98,17 @@ private fun ForYouPortfolioReview_Review(
ForYouPortfolioReview(
portfolioReviewUM = params,
modifier = Modifier.background(TangemTheme.colors3.bg.primary),
periodPickerUM = TangemSegmentedPickerUM(
items = persistentListOf(
TangemSegmentUM(id = "0", title = stringReference("Day")),
TangemSegmentUM(id = "1", title = stringReference("Week")),
TangemSegmentUM(id = "2", title = stringReference("Month")),
),
initialSelectedItem = TangemSegmentUM(id = "0", title = stringReference("Day")),
isFixed = true,
isAltSurface = true,
),
onPeriodClick = {},
)
}
}

View file

@ -7,8 +7,6 @@ import com.tangem.core.ui.ds.badge.TangemBadgeType
import com.tangem.core.ui.ds.badge.TangemBadgeUM
import com.tangem.core.ui.ds.image.TangemIconUM
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.stringReference
import com.tangem.features.foryou.impl.components.state.DonutChartUM
import com.tangem.features.foryou.impl.components.state.DonutSegmentColor
@ -23,17 +21,6 @@ import java.math.BigDecimal
internal object ForYouPortfolioReviewPreviewData {
val reviewContent = PortfolioReviewUM.Content(
periodPickerUM = TangemSegmentedPickerUM(
items = persistentListOf(
TangemSegmentUM(id = "0", title = stringReference("Day")),
TangemSegmentUM(id = "1", title = stringReference("Week")),
TangemSegmentUM(id = "2", title = stringReference("Month")),
),
initialSelectedItem = TangemSegmentUM(id = "0", title = stringReference("Day")),
isFixed = true,
isAltSurface = true,
),
onPeriodClick = {},
marketChartUM = MarketChartUM.Loaded(
donutChart = DonutChartUM.Loaded(
totalAmount = "10000$",