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.appCurrency)
api(projects.domain.common) api(projects.domain.common)
api(projects.domain.wallets) api(projects.domain.wallets)
api(projects.domain.earn)
api(projects.domain.yieldSupply)
implementation(projects.domain.account) implementation(projects.domain.account)
implementation(projects.domain.models) implementation(projects.domain.models)

View file

@ -14,6 +14,8 @@ internal data class ForYouUM(
val portfolioReviewUM: PortfolioReviewUM, val portfolioReviewUM: PortfolioReviewUM,
val earnOpportunities: EarnOpportunitiesUM, val earnOpportunities: EarnOpportunitiesUM,
val notifications: ImmutableList<ForYouNotification>, val notifications: ImmutableList<ForYouNotification>,
val periodPickerUM: TangemSegmentedPickerUM,
val onPeriodClick: (tangemSegmentUM: TangemSegmentUM) -> Unit,
) )
@Immutable @Immutable
@ -29,20 +31,32 @@ internal sealed interface PortfolioReviewUM {
data class Content( data class Content(
override val tokenList: ImmutableList<ForYouTokenListItemUM>, override val tokenList: ImmutableList<ForYouTokenListItemUM>,
override val marketChartUM: MarketChartUM, override val marketChartUM: MarketChartUM,
val periodPickerUM: TangemSegmentedPickerUM,
val onPeriodClick: (TangemSegmentUM) -> Unit,
) : PortfolioReviewUM ) : 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 @Immutable
internal sealed interface EarnOpportunitiesUM { internal sealed interface EarnOpportunitiesUM {
val tokenList: ImmutableList<ForYouTokenListItemUM> val tokenList: ImmutableList<ForYouTokenListItemUM>
/** Skeleton rows shown until the first real emission. */
data class Loading( data class Loading(
override val tokenList: ImmutableList<ForYouTokenListItemUM>, override val tokenList: ImmutableList<ForYouTokenListItemUM>,
) : EarnOpportunitiesUM ) : 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( data class Content(
override val tokenList: ImmutableList<ForYouTokenListItemUM>, override val tokenList: ImmutableList<ForYouTokenListItemUM>,
@param:StringRes val subtitleRes: Int, @param:StringRes val subtitleRes: Int,

View file

@ -2,25 +2,39 @@ package com.tangem.features.foryou.impl.model
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import arrow.core.getOrElse import arrow.core.getOrElse
import arrow.core.left
import arrow.core.right
import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.ui.ds.row.token.TangemTokenRowUM import com.tangem.core.ui.ds.row.token.TangemTokenRowUM
import com.tangem.core.ui.ds.tabs.TangemSegmentUM 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.supplier.MultiAccountStatusListSupplier
import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.common.wallets.UserWalletsListRepository 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.currency.CryptoCurrency
import com.tangem.domain.models.earn.EarnTopToken
import com.tangem.domain.models.wallet.UserWalletId 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.ForYouComponent
import com.tangem.features.foryou.impl.components.state.MarketChartUM import com.tangem.features.foryou.impl.components.state.MarketChartUM
import com.tangem.features.foryou.impl.entity.EarnOpportunitiesUM import com.tangem.features.foryou.impl.entity.*
import com.tangem.features.foryou.impl.entity.ForYouTokenListItemUM import com.tangem.features.foryou.impl.model.converter.TOP_EARN_TOKENS_BATCH_SIZE
import com.tangem.features.foryou.impl.entity.ForYouUM import com.tangem.features.foryou.impl.model.converter.earnOpportunities.ForYouEarnOpportunitiesConverter
import com.tangem.features.foryou.impl.entity.PortfolioReviewUM import com.tangem.features.foryou.impl.model.converter.portfolioReview.ForYouPortfolioReviewConverter
import com.tangem.features.foryou.impl.model.transformer.SetPortfolioReviewTransformer 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.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.combine6
import com.tangem.utils.transformer.update import com.tangem.utils.transformer.update
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList import kotlinx.collections.immutable.toPersistentList
@ -29,26 +43,34 @@ import javax.inject.Inject
@Stable @Stable
@ModelScoped @ModelScoped
@Suppress("LongParameterList")
internal class ForYouModel @Inject constructor( internal class ForYouModel @Inject constructor(
paramsContainer: ParamsContainer, paramsContainer: ParamsContainer,
userWalletsListRepository: UserWalletsListRepository, userWalletsListRepository: UserWalletsListRepository,
multiAccountStatusListSupplier: MultiAccountStatusListSupplier, multiAccountStatusListSupplier: MultiAccountStatusListSupplier,
yieldSupplyApyFlowUseCase: YieldSupplyApyFlowUseCase,
override val dispatchers: CoroutineDispatcherProvider, override val dispatchers: CoroutineDispatcherProvider,
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val getEarnTokensBatchFlowUseCase: GetEarnTokensBatchFlowUseCase,
private val stakingAvailabilityListUseCase: StakingAvailabilityListUseCase,
private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase,
private val earnErrorResolver: EarnErrorResolver,
) : Model() { ) : Model() {
private val params = paramsContainer.require<ForYouComponent.Params>() 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() private val selectedAppCurrencyFlow: StateFlow<AppCurrency> = createSelectedAppCurrencyFlow()
val uiState: StateFlow<ForYouUM> val uiState: StateFlow<ForYouUM>
field = MutableStateFlow<ForYouUM>( field = MutableStateFlow<ForYouUM>(
ForYouUM( ForYouUM(
notifications = persistentListOf(), notifications = persistentListOf(),
periodPickerUM = TangemSegmentedPickerUM(persistentListOf()),
earnOpportunities = EarnOpportunitiesUM.Loading( earnOpportunities = EarnOpportunitiesUM.Loading(
tokenList = buildList<ForYouTokenListItemUM> { tokenList = buildList<ForYouTokenListItemUM> {
repeat(4) { index -> repeat(5) { index ->
add( add(
ForYouTokenListItemUM( ForYouTokenListItemUM(
tokenRowUM = TangemTokenRowUM.Loading( tokenRowUM = TangemTokenRowUM.Loading(
@ -62,6 +84,7 @@ internal class ForYouModel @Inject constructor(
} }
}.toPersistentList(), }.toPersistentList(),
), ),
onPeriodClick = ::onPeriodClick,
portfolioReviewUM = PortfolioReviewUM.Loading( portfolioReviewUM = PortfolioReviewUM.Loading(
marketChartUM = MarketChartUM.NoData, marketChartUM = MarketChartUM.NoData,
tokenList = buildList<ForYouTokenListItemUM> { tokenList = buildList<ForYouTokenListItemUM> {
@ -83,21 +106,51 @@ internal class ForYouModel @Inject constructor(
) )
init { init {
combine( combine6(
flow = userWalletsListRepository.selectedUserWallet, flow1 = userWalletsListRepository.selectedUserWallet,
flow2 = multiAccountStatusListSupplier.invokeAsMap(), flow2 = multiAccountStatusListSupplier.invokeAsMap(),
flow3 = expandedAssetIds, flow3 = expandedPortfolioReviewAssetIds,
) { globalSelectedWallet, accountStatusList, expanded -> 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 // 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( uiState.update(
SetPortfolioReviewTransformer( SetPortfolioReviewTransformer(
accountStatusList = accountStatusList[selectedWalletId], accountStatusList = accountStatusList,
appCurrency = selectedAppCurrencyFlow.value, portfolioReviewUM = portfolioReviewUM,
expandedAssetIds = expanded, earnOpportunitiesUM = earnOpportunitiesUM,
expandClick = ::onExpandClick,
onPeriodClick = ::onPeriodClick,
onTokenClick = { currency -> onTokenClick(selectedWalletId, currency) },
), ),
) )
} }
@ -105,6 +158,40 @@ internal class ForYouModel @Inject constructor(
.launchIn(modelScope) .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> { private fun createSelectedAppCurrencyFlow(): StateFlow<AppCurrency> {
return getSelectedAppCurrencyUseCase().map { maybeAppCurrency -> return getSelectedAppCurrencyUseCase().map { maybeAppCurrency ->
maybeAppCurrency.getOrElse { AppCurrency.Default } 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 val walletId = selectedWalletId ?: return
params.callbacks.onTokenClick(walletId, currency) params.callbacks.onTokenClick(walletId, currency)
} }
private fun onExpandClick(assetId: String) { private fun onExpandPortfolioReviewClick(assetId: String) {
expandedAssetIds.update { ids -> 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 if (assetId in ids) ids - assetId else ids + assetId
} }
} }
@ -129,11 +222,9 @@ internal class ForYouModel @Inject constructor(
private fun onPeriodClick(tangemSegmentUM: TangemSegmentUM) { private fun onPeriodClick(tangemSegmentUM: TangemSegmentUM) {
uiState.update { state -> uiState.update { state ->
state.copy( state.copy(
portfolioReviewUM = (state.portfolioReviewUM as? PortfolioReviewUM.Content)?.copy( periodPickerUM = state.periodPickerUM.copy(
periodPickerUM = state.portfolioReviewUM.periodPickerUM.copy( initialSelectedItem = tangemSegmentUM,
initialSelectedItem = tangemSegmentUM, ),
),
) ?: state.portfolioReviewUM,
) )
} }
} }

View file

@ -15,7 +15,11 @@ import java.math.RoundingMode
/** Number of suggested top-earn tokens shown in the earn-opportunities block. */ /** Number of suggested top-earn tokens shown in the earn-opportunities block. */
internal const val FOR_YOU_TOP_EARN_TOKENS_COUNT = 5 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 internal const val TOP_EARN_TOKENS_BATCH_SIZE = 30
/** Divisor converting backend percent values (5.5) to fractions (0.055). */ /** 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. * 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 * accounts are ordered by it, descending
*/ */
internal data class EarnOpportunities( internal data class EarnOpportunities(
val account: Account.CryptoPortfolio, val account: Account.CryptoPortfolio,
val earnCurrencues: Map<CryptoCurrencyStatus, EarnApyInfo>, val earnCurrencies: Map<CryptoCurrencyStatus, EarnApyInfo>,
val accountPotentialReward: BigDecimal, val accountPotentialReward: BigDecimal,
) )

View file

@ -75,7 +75,7 @@ internal class ForYouEarnOpportunitiesConverter(
EarnOpportunities( EarnOpportunities(
account = cryptoAccountStatus.account, account = cryptoAccountStatus.account,
earnCurrencues = tokenList.toMap(), earnCurrencies = tokenList.toMap(),
accountPotentialReward = accountPotentialReward, accountPotentialReward = accountPotentialReward,
) )
} }
@ -84,10 +84,14 @@ internal class ForYouEarnOpportunitiesConverter(
return when { return when {
data.isEmpty() -> { data.isEmpty() -> {
ForYouEarnOpportunitiesNoTokensConverter(topEarnTokens).convert(data) ForYouEarnOpportunitiesNoTokensConverter(
topEarnTokens = topEarnTokens,
).convert(data)
} }
data.all { earn -> earn.earnCurrencues.all { entry -> entry.value.isActive } } -> { data.all { earn -> earn.earnCurrencies.all { entry -> entry.value.isActive } } -> {
ForYouEarnOpportunitiesTokensActiveConverter(topEarnTokens).convert(data) ForYouEarnOpportunitiesTokensActiveConverter(
topEarnTokens = topEarnTokens,
).convert(data)
} }
else -> { else -> {
ForYouEarnOpportunitiesPotentialRewardsConverter( ForYouEarnOpportunitiesPotentialRewardsConverter(
@ -134,7 +138,7 @@ internal class ForYouEarnOpportunitiesConverter(
currencyStatus = cryptoCurrencyStatus, currencyStatus = cryptoCurrencyStatus,
stakingApyMap = stakingApyMap, stakingApyMap = stakingApyMap,
) )
if (stakingInfo.rate != null) { if (stakingInfo != null) {
return EarnApyInfo( return EarnApyInfo(
isActive = stakingInfo.isActive, isActive = stakingInfo.isActive,
apy = stakingInfo.rate, apy = stakingInfo.rate,
@ -154,10 +158,9 @@ internal class ForYouEarnOpportunitiesConverter(
private fun findStakingRate( private fun findStakingRate(
currencyStatus: CryptoCurrencyStatus, currencyStatus: CryptoCurrencyStatus,
stakingApyMap: Map<CryptoCurrency, StakingAvailability>, stakingApyMap: Map<CryptoCurrency, StakingAvailability>,
): StakingLocalInfo { ): StakingLocalInfo? {
val availability = stakingApyMap[currencyStatus.currency] val availability = stakingApyMap[currencyStatus.currency]
val option = availability?.optionOrNull val option = availability?.optionOrNull ?: return null
?: return StakingLocalInfo(rate = null, isActive = false, rewardType = null)
val stakingBalance = currencyStatus.value.stakingBalance as? StakingBalance.Data val stakingBalance = currencyStatus.value.stakingBalance as? StakingBalance.Data
val stakeKitBalance = stakingBalance as? StakingBalance.Data.StakeKit 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. // Full = no free capacity: show the badge only for tokens that already have a stake.
if (availability is StakingAvailability.Full && !isActive) { if (availability is StakingAvailability.Full && !isActive) {
return StakingLocalInfo(rate = null, isActive = false, rewardType = null) return null
} }
val rateInfo = when (option) { val rateInfo = when (option) {
@ -196,18 +199,18 @@ internal class ForYouEarnOpportunitiesConverter(
} }
.maxByOrNull { it.rate } .maxByOrNull { it.rate }
} }
} } ?: return null
return StakingLocalInfo( return StakingLocalInfo(
rate = rateInfo?.rate, rate = rateInfo.rate,
isActive = isActive, isActive = isActive,
rewardType = rateInfo?.type, rewardType = rateInfo.type,
) )
} }
private data class StakingLocalInfo( private data class StakingLocalInfo(
val rate: BigDecimal?, val rate: BigDecimal,
val isActive: Boolean, 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 kotlinx.collections.immutable.toPersistentList
import java.math.BigDecimal 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( internal class ForYouEarnOpportunitiesPotentialRewardsConverter(
private val appCurrency: AppCurrency, private val appCurrency: AppCurrency,
private val isAccountsModeEnabled: Boolean, private val isAccountsModeEnabled: Boolean,
@ -51,16 +60,16 @@ internal class ForYouEarnOpportunitiesPotentialRewardsConverter(
tokenRowUM = createAssetRow( tokenRowUM = createAssetRow(
account = earnData.account, account = earnData.account,
potentialReward = earnData.accountPotentialReward, potentialReward = earnData.accountPotentialReward,
tokenCount = earnData.earnCurrencues.size, tokenCount = earnData.earnCurrencies.size,
), ),
tokenList = rowConverter.convertList(earnData.earnCurrencues.toList()) tokenList = rowConverter.convertList(earnData.earnCurrencies.toList())
.toPersistentList(), .toPersistentList(),
isExpanded = earnData.account.accountId.value in expandedAssetIds, isExpanded = earnData.account.accountId.value in expandedAssetIds,
isExpandable = true, isExpandable = true,
), ),
) )
} else { } else {
earnData.earnCurrencues.map { token -> earnData.earnCurrencies.map { token ->
ForYouTokenListItemUM( ForYouTokenListItemUM(
tokenRowUM = rowConverter.convert(token.toPair()), tokenRowUM = rowConverter.convert(token.toPair()),
tokenList = persistentListOf(), tokenList = persistentListOf(),

View file

@ -22,6 +22,13 @@ import com.tangem.utils.extensions.orZero
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import java.math.BigDecimal 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( internal class ForYouEarnOpportunitiesTokenRowConverter(
private val appCurrency: AppCurrency, private val appCurrency: AppCurrency,
) : Converter<Pair<CryptoCurrencyStatus, EarnApyInfo>, TangemTokenRowUM> { ) : Converter<Pair<CryptoCurrencyStatus, EarnApyInfo>, TangemTokenRowUM> {

View file

@ -24,7 +24,7 @@ internal class ForYouEarnOpportunitiesTokensActiveConverter(
override fun convert(value: List<EarnOpportunities>): EarnOpportunitiesUM { override fun convert(value: List<EarnOpportunities>): EarnOpportunitiesUM {
val activeAssetKeys = value val activeAssetKeys = value
.flatMap { opportunities -> opportunities.earnCurrencues.keys } .flatMap { opportunities -> opportunities.earnCurrencies.keys }
.map { status -> status.currency.forYouEarnAssetKey() } .map { status -> status.currency.forYouEarnAssetKey() }
.toSet() .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.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.components.currency.icon.CurrencyIconState 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.fiat
import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.percent 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.appcurrency.model.AppCurrency
import com.tangem.domain.models.TotalFiatBalance
import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus 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.R
import com.tangem.features.foryou.impl.entity.ForYouTokenListItemUM 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.converter.Converter
import com.tangem.utils.extensions.isZero
import com.tangem.utils.extensions.orZero import com.tangem.utils.extensions.orZero
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList import kotlinx.collections.immutable.toPersistentList
import java.math.BigDecimal 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, * (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. * 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; * 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). * Modelled on `TokenListStateConverter` (a list converter delegating to a per-item converter).
*/ */
internal class ForYouTokenListConverter( internal class ForYouPortfolioReviewConverter(
private val appCurrency: AppCurrency, private val appCurrency: AppCurrency,
private val totalFiatBalance: BigDecimal,
private val expandedAssetIds: Set<String>, private val expandedAssetIds: Set<String>,
private val expandClick: (assetId: String) -> Unit, private val expandClick: (assetId: String) -> Unit,
private val otherAssets: List<Pair<List<CryptoCurrencyStatus>, BigDecimal>>, private val onTokenClick: (UserWalletId, CryptoCurrency) -> Unit,
private val onTokenClick: (CryptoCurrency) -> Unit, ) : Converter<AccountStatusList?, PortfolioReviewUM> {
) : Converter<List<CryptoCurrencyStatus>, ImmutableList<ForYouTokenListItemUM>> {
private val iconConverter = CryptoCurrencyToIconStateConverter() private val iconConverter = CryptoCurrencyToIconStateConverter()
private val rowConverter = ForYouTokenRowConverter(
appCurrency = appCurrency,
totalFiatBalance = totalFiatBalance,
onTokenClick = onTokenClick,
)
override fun convert(value: List<CryptoCurrencyStatus>): ImmutableList<ForYouTokenListItemUM> { override fun convert(value: AccountStatusList?): PortfolioReviewUM {
val assetItems = value 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() } .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. // Assets beyond the top ones are collapsed into a single non-expandable "Other" row at the bottom.
return if (otherAssets.count() > 0) { val tokenList = if (otherAssets.count() > 0) {
assetItems + createOtherItem() assetItems + createOtherItem(otherAssets, totalFiatBalance)
} else { } else {
assetItems assetItems
}.toPersistentList() }.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 // 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, // 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. // 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 } .groupBy { it.currency.network.id.rawId }
.values .values
.sortedByDescending { group -> group.sumOf { it.value.fiatAmount.orZero() } } .sortedByDescending { group -> group.sumOf { it.value.fiatAmount.orZero() } }
val rowConverter = ForYouPortfolioReviewTokenRowConverter(
userWalletId = userWalletId,
appCurrency = appCurrency,
totalFiatBalance = totalFiatBalance,
onTokenClick = onTokenClick,
)
return ForYouTokenListItemUM( return ForYouTokenListItemUM(
tokenRowUM = createAssetRow( tokenRowUM = createAssetRow(
userWalletId = userWalletId,
assetId = assetId, assetId = assetId,
currencies = currencies, currencies = currencies,
networkCount = networkGroups.size, networkCount = networkGroups.size,
totalFiatBalance = totalFiatBalance,
), ),
tokenList = networkGroups.map(rowConverter::convertNetworkGroup).toPersistentList(), tokenList = networkGroups.map(rowConverter::convertNetworkGroup).toPersistentList(),
isExpanded = assetId in expandedAssetIds, isExpanded = assetId in expandedAssetIds,
@ -81,8 +135,10 @@ internal class ForYouTokenListConverter(
private fun createAssetRow( private fun createAssetRow(
assetId: String, assetId: String,
userWalletId: UserWalletId?,
currencies: List<CryptoCurrencyStatus>, currencies: List<CryptoCurrencyStatus>,
networkCount: Int, networkCount: Int,
totalFiatBalance: BigDecimal,
): TangemTokenRowUM { ): TangemTokenRowUM {
if (currencies.all { it.value is CryptoCurrencyStatus.Loading }) { if (currencies.all { it.value is CryptoCurrencyStatus.Loading }) {
return TangemTokenRowUM.Loading(id = assetId) return TangemTokenRowUM.Loading(id = assetId)
@ -91,6 +147,12 @@ internal class ForYouTokenListConverter(
val asset = currencies.first() val asset = currencies.first()
val assetFiatBalance = currencies.sumOf { it.value.fiatAmount.orZero() } 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 endContent = rowConverter.toEndContent(statuses = currencies, fiatAmount = assetFiatBalance)
val onlyCryptoCurrency = currencies.firstOrNull()?.currency 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 } val otherAssetsBalance = otherAssets.sumOf { (_, assetBalance) -> assetBalance }
return ForYouTokenListItemUM( return ForYouTokenListItemUM(
tokenRowUM = TangemTokenRowUM.Content( tokenRowUM = TangemTokenRowUM.Content(
@ -158,5 +223,6 @@ internal class ForYouTokenListConverter(
private companion object { private companion object {
const val OTHER_ROW_ID = "for_you_other_assets" 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.extensions.stringReference
import com.tangem.core.ui.format.bigdecimal.fiat 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.appcurrency.model.AppCurrency
import com.tangem.domain.models.TotalFiatBalance import com.tangem.domain.models.TotalFiatBalance
import com.tangem.domain.models.currency.CryptoCurrencyStatus 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.converter.Converter
import com.tangem.utils.extensions.orZero import com.tangem.utils.extensions.orZero
import kotlinx.collections.immutable.toPersistentList import kotlinx.collections.immutable.toPersistentList
import java.math.BigDecimal import java.math.BigDecimal
internal class ForYouMarketChartConverter( internal class ForYouPortfolioReviewMarketChartConverter(
private val appCurrency: AppCurrency, private val appCurrency: AppCurrency,
private val topAssets: List<Pair<List<CryptoCurrencyStatus>, BigDecimal>>, private val topAssets: List<Pair<List<CryptoCurrencyStatus>, BigDecimal>>,
) : Converter<TotalFiatBalance?, MarketChartUM> { ) : 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 androidx.compose.ui.text.SpanStyle
import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter 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.StatusSource
import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus 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.StringsSigns
import com.tangem.utils.extensions.orZero import com.tangem.utils.extensions.orZero
import kotlinx.collections.immutable.persistentListOf 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 * classification (they contribute nothing yet). The cache/flicker indicators derive from the most
* conservative [CryptoCurrencyStatus.Sources.total] across the contributing statuses. * conservative [CryptoCurrencyStatus.Sources.total] across the contributing statuses.
*/ */
internal class ForYouTokenRowConverter( internal class ForYouPortfolioReviewTokenRowConverter(
private val appCurrency: AppCurrency, private val appCurrency: AppCurrency,
private val userWalletId: UserWalletId?,
private val totalFiatBalance: BigDecimal, private val totalFiatBalance: BigDecimal,
private val onTokenClick: (CryptoCurrency) -> Unit, private val onTokenClick: (UserWalletId, CryptoCurrency) -> Unit,
) { ) {
private val iconConverter = CryptoCurrencyToIconStateConverter() private val iconConverter = CryptoCurrencyToIconStateConverter()
@ -66,7 +70,7 @@ internal class ForYouTokenRowConverter(
subtitleUM = toRowSubtitle(state, currency, cryptoAmount), subtitleUM = toRowSubtitle(state, currency, cryptoAmount),
topEndContentUM = toRowTopEnd(state, fiatAmount), topEndContentUM = toRowTopEnd(state, fiatAmount),
bottomEndContentUM = toRowBottomEnd(state, fiatAmount), bottomEndContentUM = toRowBottomEnd(state, fiatAmount),
onItemClick = { onTokenClick(currency) }, onItemClick = { if (userWalletId != null) onTokenClick(userWalletId, currency) },
onItemLongClick = null, 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( private fun toRowTitle(currency: CryptoCurrency): TangemTokenRowUM.TitleUM = TangemTokenRowUM.TitleUM.Content(
text = stringReference(currency.symbol), text = stringReference(currency.name),
badge = forYouPlaceholderBadge(), 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.ds.tabs.TangemSegmentedPickerUM
import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.account.models.AccountStatusList 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.StatusSource
import com.tangem.domain.models.TotalFiatBalance 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.ForYouUM
import com.tangem.features.foryou.impl.entity.PortfolioReviewUM import com.tangem.features.foryou.impl.entity.PortfolioReviewUM
import com.tangem.features.foryou.impl.model.ForYouNotification 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.portfolioReview.ForYouPortfolioReviewConverter
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.utils.transformer.Transformer import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
/** /**
* Builds the [ForYouUM] state for the For You screen: the outdated-data notifications plus the portfolio * Applies one combined emission to the [ForYouUM] state: sets the pre-built portfolio-review and
* review (market chart, period picker and the grouped token list). * 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 * subsequent refreshes the previous picker is carried over so the user's selection is not reset.
* it is not reset on every balance refresh.
* *
* Modelled on `SetTokenListTransformer` (a transformer that rebuilds the state while delegating the * 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( internal class SetPortfolioReviewTransformer(
private val accountStatusList: AccountStatusList?, private val accountStatusList: AccountStatusList?,
private val appCurrency: AppCurrency, private val portfolioReviewUM: PortfolioReviewUM,
private val expandedAssetIds: Set<String>, private val earnOpportunitiesUM: EarnOpportunitiesUM,
private val expandClick: (assetId: String) -> Unit,
private val onPeriodClick: (TangemSegmentUM) -> Unit,
private val onTokenClick: (CryptoCurrency) -> Unit,
) : Transformer<ForYouUM> { ) : Transformer<ForYouUM> {
override fun transform(prevState: ForYouUM): ForYouUM { override fun transform(prevState: ForYouUM): ForYouUM {
val currencies = accountStatusList?.flattenCurrencies().orEmpty()
val loadedBalance = accountStatusList?.totalFiatBalance as? TotalFiatBalance.Loaded 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( return prevState.copy(
notifications = if (loadedBalance?.source == StatusSource.ONLY_CACHE) { notifications = if (loadedBalance?.source == StatusSource.ONLY_CACHE) {
persistentListOf(ForYouNotification.UsedOutdatedData) persistentListOf(ForYouNotification.UsedOutdatedData)
} else { } else {
persistentListOf() persistentListOf()
}, },
portfolioReviewUM = PortfolioReviewUM.Content( earnOpportunities = earnOpportunitiesUM,
periodPickerUM = when (prevState.portfolioReviewUM) { portfolioReviewUM = portfolioReviewUM,
is PortfolioReviewUM.Content -> prevState.portfolioReviewUM.periodPickerUM periodPickerUM = when (prevState.portfolioReviewUM) {
is PortfolioReviewUM.Loading -> createPeriodPicker() is PortfolioReviewUM.Loading -> createPeriodPicker()
}, is PortfolioReviewUM.Content -> prevState.periodPickerUM
tokenList = tokenList, },
marketChartUM = marketChartUM,
onPeriodClick = onPeriodClick,
),
) )
} }
@ -109,8 +63,4 @@ internal class SetPortfolioReviewTransformer(
isAltSurface = true, 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 androidx.compose.ui.util.fastForEachIndexed
import com.tangem.core.ui.components.SpacerH import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState 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.ds2.messagebanner.TangemMessageBanner
import com.tangem.core.ui.extensions.conditional 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.LocalMainBottomSheetColor
import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign import com.tangem.core.ui.res.TangemThemePreviewRedesign
@ -78,6 +81,15 @@ internal fun ForYouContent(
ForYouPortfolioReview( ForYouPortfolioReview(
portfolioReviewUM = forYouUM.portfolioReviewUM, 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), modifier = Modifier.padding(horizontal = 16.dp),
) )
@ -114,6 +126,17 @@ private class ForYouContentPreviewProvider : PreviewParameterProvider<ForYouUM>
notifications = persistentListOf(ForYouNotification.UsedOutdatedData), notifications = persistentListOf(ForYouNotification.UsedOutdatedData),
earnOpportunities = ForYouEarnOpportunitiesPreviewData.tokensRewards, earnOpportunities = ForYouEarnOpportunitiesPreviewData.tokensRewards,
portfolioReviewUM = ForYouPortfolioReviewPreviewData.reviewContent, 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.components.SpacerH
import com.tangem.core.ui.ds.image.TangemIconUM 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
import com.tangem.core.ui.ds.tabs.TangemSegmentUM
import com.tangem.core.ui.ds.tabs.TangemSegmentedPicker 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.badge.TangemBadge
import com.tangem.core.ui.ds2.shimmers.TangemShimmer import com.tangem.core.ui.ds2.shimmers.TangemShimmer
import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.stringReference
@ -34,7 +36,12 @@ import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList import kotlinx.collections.immutable.toPersistentList
@Composable @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) { Column(modifier = modifier) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@ -64,8 +71,8 @@ internal fun ForYouPortfolioReview(portfolioReviewUM: PortfolioReviewUM, modifie
when (portfolioReviewUM) { when (portfolioReviewUM) {
is PortfolioReviewUM.Content -> { is PortfolioReviewUM.Content -> {
TangemSegmentedPicker( TangemSegmentedPicker(
tangemSegmentedPickerUM = portfolioReviewUM.periodPickerUM, tangemSegmentedPickerUM = periodPickerUM,
onClick = portfolioReviewUM.onPeriodClick, onClick = onPeriodClick,
) )
} }
is PortfolioReviewUM.Loading -> TangemShimmer( is PortfolioReviewUM.Loading -> TangemShimmer(
@ -91,6 +98,17 @@ private fun ForYouPortfolioReview_Review(
ForYouPortfolioReview( ForYouPortfolioReview(
portfolioReviewUM = params, portfolioReviewUM = params,
modifier = Modifier.background(TangemTheme.colors3.bg.primary), 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.badge.TangemBadgeUM
import com.tangem.core.ui.ds.image.TangemIconUM 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
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.core.ui.extensions.stringReference
import com.tangem.features.foryou.impl.components.state.DonutChartUM 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.DonutSegmentColor
@ -23,17 +21,6 @@ import java.math.BigDecimal
internal object ForYouPortfolioReviewPreviewData { internal object ForYouPortfolioReviewPreviewData {
val reviewContent = PortfolioReviewUM.Content( 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( marketChartUM = MarketChartUM.Loaded(
donutChart = DonutChartUM.Loaded( donutChart = DonutChartUM.Loaded(
totalAmount = "10000$", totalAmount = "10000$",