diff --git a/features/for-you/impl/build.gradle.kts b/features/for-you/impl/build.gradle.kts index fcdb2d8087..d7d88b403c 100644 --- a/features/for-you/impl/build.gradle.kts +++ b/features/for-you/impl/build.gradle.kts @@ -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) diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/entity/ForYouUM.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/entity/ForYouUM.kt index 564a3fa05a..2291181eaf 100644 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/entity/ForYouUM.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/entity/ForYouUM.kt @@ -14,6 +14,8 @@ internal data class ForYouUM( val portfolioReviewUM: PortfolioReviewUM, val earnOpportunities: EarnOpportunitiesUM, val notifications: ImmutableList, + val periodPickerUM: TangemSegmentedPickerUM, + val onPeriodClick: (tangemSegmentUM: TangemSegmentUM) -> Unit, ) @Immutable @@ -29,20 +31,32 @@ internal sealed interface PortfolioReviewUM { data class Content( override val tokenList: ImmutableList, 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 + /** Skeleton rows shown until the first real emission. */ data class Loading( override val tokenList: ImmutableList, ) : 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, @param:StringRes val subtitleRes: Int, diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/ForYouModel.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/ForYouModel.kt index 8164f5d156..561d48003a 100644 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/ForYouModel.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/ForYouModel.kt @@ -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() - private val expandedAssetIds = MutableStateFlow>(value = emptySet()) + private val expandedPortfolioReviewAssetIds = MutableStateFlow>(value = emptySet()) + private val expandedEarnOpportunitiesAssetIds = MutableStateFlow>(value = emptySet()) private val selectedAppCurrencyFlow: StateFlow = createSelectedAppCurrencyFlow() val uiState: StateFlow field = MutableStateFlow( ForYouUM( notifications = persistentListOf(), + periodPickerUM = TangemSegmentedPickerUM(persistentListOf()), earnOpportunities = EarnOpportunitiesUM.Loading( tokenList = buildList { - 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 { @@ -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 { + val actionsFlow = MutableSharedFlow>(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 { 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, + ), ) } } diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/ForYouUtils.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/ForYouUtils.kt index 6b2425900a..d667614973 100644 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/ForYouUtils.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/ForYouUtils.kt @@ -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, + val earnCurrencies: Map, val accountPotentialReward: BigDecimal, ) \ No newline at end of file diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesConverter.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesConverter.kt index a95b2a2f69..8f4a348934 100644 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesConverter.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesConverter.kt @@ -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, - ): 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, ) } \ No newline at end of file diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesPotentialRewardsConverter.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesPotentialRewardsConverter.kt index 47d65c42b3..b93413e988 100644 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesPotentialRewardsConverter.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesPotentialRewardsConverter.kt @@ -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(), diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTokenRowConverter.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTokenRowConverter.kt index 7e9b237516..cb60890b9d 100644 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTokenRowConverter.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTokenRowConverter.kt @@ -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, TangemTokenRowUM> { diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTokensActiveConverter.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTokensActiveConverter.kt index dbe05ab54a..2c0aaadc55 100644 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTokensActiveConverter.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTokensActiveConverter.kt @@ -24,7 +24,7 @@ internal class ForYouEarnOpportunitiesTokensActiveConverter( override fun convert(value: List): EarnOpportunitiesUM { val activeAssetKeys = value - .flatMap { opportunities -> opportunities.earnCurrencues.keys } + .flatMap { opportunities -> opportunities.earnCurrencies.keys } .map { status -> status.currency.forYouEarnAssetKey() } .toSet() diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/ForYouTokenListConverter.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/portfolioReview/ForYouPortfolioReviewConverter.kt similarity index 58% rename from features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/ForYouTokenListConverter.kt rename to features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/portfolioReview/ForYouPortfolioReviewConverter.kt index 1b9f893650..6fd651133f 100644 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/ForYouTokenListConverter.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/portfolioReview/ForYouPortfolioReviewConverter.kt @@ -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, private val expandClick: (assetId: String) -> Unit, - private val otherAssets: List, BigDecimal>>, - private val onTokenClick: (CryptoCurrency) -> Unit, -) : Converter, ImmutableList> { + private val onTokenClick: (UserWalletId, CryptoCurrency) -> Unit, +) : Converter { private val iconConverter = CryptoCurrencyToIconStateConverter() - private val rowConverter = ForYouTokenRowConverter( - appCurrency = appCurrency, - totalFiatBalance = totalFiatBalance, - onTokenClick = onTokenClick, - ) - override fun convert(value: List): ImmutableList { - 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): ForYouTokenListItemUM { + private fun createListItem( + userWalletId: UserWalletId?, + assetId: String, + currencies: List, + 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, 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, 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 } } \ No newline at end of file diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/ForYouMarketChartConverter.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/portfolioReview/ForYouPortfolioReviewMarketChartConverter.kt similarity index 81% rename from features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/ForYouMarketChartConverter.kt rename to features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/portfolioReview/ForYouPortfolioReviewMarketChartConverter.kt index e767adc242..c727d3de6e 100644 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/ForYouMarketChartConverter.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/portfolioReview/ForYouPortfolioReviewMarketChartConverter.kt @@ -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, BigDecimal>>, ) : Converter { diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/ForYouTokenRowConverter.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/portfolioReview/ForYouPortfolioReviewTokenRowConverter.kt similarity index 93% rename from features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/ForYouTokenRowConverter.kt rename to features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/portfolioReview/ForYouPortfolioReviewTokenRowConverter.kt index 1a9f41f22a..41a34e5d3a 100644 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/ForYouTokenRowConverter.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/portfolioReview/ForYouPortfolioReviewTokenRowConverter.kt @@ -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(), ) diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/transformer/SetPortfolioReviewTransformer.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/transformer/SetPortfolioReviewTransformer.kt index b07e6bc239..da9d96179e 100644 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/transformer/SetPortfolioReviewTransformer.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/transformer/SetPortfolioReviewTransformer.kt @@ -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, - 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 { 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 - } } \ No newline at end of file diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/ForYouContent.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/ForYouContent.kt index 7314fb2afc..63a964717d 100644 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/ForYouContent.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/ForYouContent.kt @@ -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 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 = {}, ), ) } diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/ForYouPortfolioReview.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/ForYouPortfolioReview.kt index 5cf487bab4..e6b07656e9 100644 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/ForYouPortfolioReview.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/ForYouPortfolioReview.kt @@ -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 = {}, ) } } diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/preview/ForYouPortfolioReviewPreviewData.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/preview/ForYouPortfolioReviewPreviewData.kt index 5bb66c7949..75aea81cde 100644 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/preview/ForYouPortfolioReviewPreviewData.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/preview/ForYouPortfolioReviewPreviewData.kt @@ -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$", diff --git a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/ForYouModelTest.kt b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/ForYouModelTest.kt index 8b5a850ce7..2dd1deb193 100644 --- a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/ForYouModelTest.kt +++ b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/ForYouModelTest.kt @@ -7,23 +7,49 @@ import com.tangem.core.decompose.model.MutableParamsContainer import com.tangem.core.ui.ds.row.token.TangemTokenRowUM import com.tangem.domain.account.models.AccountStatusList 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.EarnTokensBatchFlow +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.StatusSource import com.tangem.domain.models.TotalFiatBalance +import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.earn.EarnError +import com.tangem.domain.models.earn.EarnRewardType +import com.tangem.domain.models.earn.EarnToken +import com.tangem.domain.models.earn.EarnTokenWithCurrency +import com.tangem.domain.models.earn.EarnType import com.tangem.domain.models.network.Network import com.tangem.domain.models.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.PortfolioReviewUM +import com.tangem.features.foryou.impl.model.converter.TOP_EARN_TOKENS_BATCH_SIZE +import com.tangem.pagination.Batch +import com.tangem.pagination.BatchAction +import com.tangem.pagination.BatchListState +import com.tangem.pagination.PaginationStatus +import com.tangem.test.mock.MockAccounts import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.CapturingSlot +import io.mockk.coEvery import io.mockk.every import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope @@ -41,6 +67,11 @@ internal class ForYouModelTest { private val userWalletsListRepository: UserWalletsListRepository = mockk() private val multiAccountStatusListSupplier: MultiAccountStatusListSupplier = mockk() private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase = mockk() + private val yieldSupplyApyFlowUseCase: YieldSupplyApyFlowUseCase = mockk() + private val getEarnTokensBatchFlowUseCase: GetEarnTokensBatchFlowUseCase = mockk() + private val stakingAvailabilityListUseCase: StakingAvailabilityListUseCase = mockk() + private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase = mockk() + private val earnErrorResolver: EarnErrorResolver = mockk() private var model: ForYouModel? = null @@ -49,6 +80,10 @@ internal class ForYouModelTest { // Default: a real, non-empty emission so the model's `getOrElse { Default }` mapping path is // actually exercised in every test, not bypassed by an empty flow. every { getSelectedAppCurrencyUseCase() } returns flowOf(AppCurrency.Default.right()) + every { yieldSupplyApyFlowUseCase() } returns flowOf(emptyMap()) + coEvery { stakingAvailabilityListUseCase.invokeSync(any(), any()) } returns emptyMap() + coEvery { isAccountsModeEnabledUseCase.invokeSync() } returns false + stubTopEarnTokens(status = PaginationStatus.None) } @AfterEach @@ -75,6 +110,21 @@ internal class ForYouModelTest { assertThat(loading.tokenList.all { it.tokenRowUM is TangemTokenRowUM.Loading }).isTrue() assertThat(loading.marketChartUM).isEqualTo(MarketChartUM.NoData) } + + @Test + fun `GIVEN model created WHEN not yet advanced THEN earn section is Loading with skeleton rows`() = runTest { + // Arrange + every { userWalletsListRepository.selectedUserWallet } returns MutableStateFlow(null) + every { multiAccountStatusListSupplier.invokeAsMap() } returns flowOf(linkedMapOf()) + + // Act + val model = createModel(testScope = this) + + // Assert + val loading = model.uiState.value.earnOpportunities as EarnOpportunitiesUM.Loading + assertThat(loading.tokenList).hasSize(5) + assertThat(loading.tokenList.all { it.tokenRowUM is TangemTokenRowUM.Loading }).isTrue() + } } @Nested @@ -84,10 +134,7 @@ internal class ForYouModelTest { fun `GIVEN selected wallet and statuses emitted WHEN advanced THEN uiState becomes Content`() = runTest { // Arrange val currency = createCoin(rawCurrencyId = "btc", symbol = "BTC") - stubSelectedWallet( - currencies = listOf(createStatus(currency, loadedValue(BigDecimal("100")))), - totalFiatBalance = BigDecimal("100"), - ) + stubSelectedWallet(currencies = listOf(createStatus(currency, loadedValue(BigDecimal("100"))))) // Act val model = createModel(testScope = this) @@ -107,7 +154,6 @@ internal class ForYouModelTest { val currency = createCoin(rawCurrencyId = "btc", symbol = "BTC") stubSelectedWallet( currencies = listOf(createStatus(currency, loadedValue(BigDecimal("100")))), - totalFiatBalance = BigDecimal("100"), source = StatusSource.ONLY_CACHE, ) @@ -118,6 +164,71 @@ internal class ForYouModelTest { // Assert assertThat(model.uiState.value.notifications).containsExactly(ForYouNotification.UsedOutdatedData) } + + @Test + fun `GIVEN nothing earn-eligible and loaded suggestions WHEN advanced THEN earn section suggests them`() = + runTest { + // Arrange — the portfolio coin has no earn option; the top-earn batch has one suggestion + val currency = createCoin(rawCurrencyId = "btc", symbol = "BTC") + stubSelectedWallet(currencies = listOf(createStatus(currency, loadedValue(BigDecimal("100"))))) + stubTopEarnTokens( + status = PaginationStatus.EndOfPagination, + suggestions = listOf(createTopEarnSuggestion()), + ) + + // Act + val model = createModel(testScope = this) + advanceUntilIdle() + + // Assert + val earn = model.uiState.value.earnOpportunities as EarnOpportunitiesUM.Content + assertThat(earn.tokenList.map { it.tokenRowUM.id }).containsExactly("coin-solana") + } + + @Test + fun `GIVEN model created WHEN advanced THEN top-earn tokens requested as one full-config reload`() = runTest { + // Arrange + val contextSlot: CapturingSlot = slot() + val batchFlow: EarnTokensBatchFlow = mockk { + every { state } returns MutableStateFlow( + BatchListState(data = emptyList(), status = PaginationStatus.None), + ) + } + every { getEarnTokensBatchFlowUseCase(capture(contextSlot), any()) } returns batchFlow + every { userWalletsListRepository.selectedUserWallet } returns MutableStateFlow(null) + every { multiAccountStatusListSupplier.invokeAsMap() } returns flowOf(linkedMapOf()) + + // Act + createModel(testScope = this) + advanceUntilIdle() + + // Assert — a single Reload action with the unfiltered config, sized for one batch + verify { getEarnTokensBatchFlowUseCase(any(), TOP_EARN_TOKENS_BATCH_SIZE) } + val action = contextSlot.captured.actionsFlow.first() as BatchAction.Reload + assertThat(action.requestParams).isEqualTo( + EarnTokensListConfig(type = null, networks = null, isForEarn = false), + ) + } + + @Test + fun `GIVEN top-earn batch fails to load WHEN advanced THEN error resolved and no suggestions shown`() = + runTest { + // Arrange + val failure = RuntimeException("network down") + every { earnErrorResolver.resolve(failure) } returns EarnError.NotHttpError() + stubTopEarnTokens(status = PaginationStatus.InitialLoadingError(failure)) + every { userWalletsListRepository.selectedUserWallet } returns MutableStateFlow(null) + every { multiAccountStatusListSupplier.invokeAsMap() } returns flowOf(linkedMapOf()) + + // Act + val model = createModel(testScope = this) + advanceUntilIdle() + + // Assert + verify { earnErrorResolver.resolve(failure) } + val earn = model.uiState.value.earnOpportunities as EarnOpportunitiesUM.Content + assertThat(earn.tokenList).isEmpty() + } } @Nested @@ -127,10 +238,7 @@ internal class ForYouModelTest { fun `GIVEN asset row clicked WHEN clicked again THEN isExpanded toggles back to false`() = runTest { // Arrange val currency = createCoin(rawCurrencyId = "btc", symbol = "BTC") - stubSelectedWallet( - currencies = listOf(createStatus(currency, loadedValue(BigDecimal("100")))), - totalFiatBalance = BigDecimal("100"), - ) + stubSelectedWallet(currencies = listOf(createStatus(currency, loadedValue(BigDecimal("100"))))) val model = createModel(testScope = this) advanceUntilIdle() val initialContent = model.uiState.value.portfolioReviewUM as PortfolioReviewUM.Content @@ -162,22 +270,19 @@ internal class ForYouModelTest { runTest { // Arrange val currency = createCoin(rawCurrencyId = "btc", symbol = "BTC") - stubSelectedWallet( - currencies = listOf(createStatus(currency, loadedValue(BigDecimal("100")))), - totalFiatBalance = BigDecimal("100"), - ) + stubSelectedWallet(currencies = listOf(createStatus(currency, loadedValue(BigDecimal("100"))))) val model = createModel(testScope = this) advanceUntilIdle() - val contentBefore = model.uiState.value.portfolioReviewUM as PortfolioReviewUM.Content - val weekItem = contentBefore.periodPickerUM.items[1] + val stateBefore = model.uiState.value + val weekItem = stateBefore.periodPickerUM.items[1] // Act - contentBefore.onPeriodClick(weekItem) + stateBefore.onPeriodClick(weekItem) // Assert - val contentAfter = model.uiState.value.portfolioReviewUM as PortfolioReviewUM.Content - assertThat(contentAfter.periodPickerUM.initialSelectedItem).isEqualTo(weekItem) - assertThat(contentAfter.tokenList).isEqualTo(contentBefore.tokenList) + val stateAfter = model.uiState.value + assertThat(stateAfter.periodPickerUM.initialSelectedItem).isEqualTo(weekItem) + assertThat(stateAfter.portfolioReviewUM).isEqualTo(stateBefore.portfolioReviewUM) } } @@ -187,18 +292,51 @@ internal class ForYouModelTest { /** Wires the repository + supplier so the model derives Content from a single selected wallet. */ private fun stubSelectedWallet( currencies: List, - totalFiatBalance: BigDecimal, source: StatusSource = StatusSource.ACTUAL, ) { - val wallet = MockUserWalletFactory.create().copy(walletId = UserWalletId("01")) + val wallet = MockUserWalletFactory.create().copy(walletId = WALLET_ID) every { userWalletsListRepository.selectedUserWallet } returns MutableStateFlow(wallet) every { multiAccountStatusListSupplier.invokeAsMap() } returns flowOf( linkedMapOf( - wallet.walletId to createAccountStatusList(currencies, totalFiatBalance, source), + wallet.walletId to createAccountStatusList(currencies, source), ), ) } + private fun stubTopEarnTokens( + status: PaginationStatus>, + suggestions: List = emptyList(), + ) { + val batches = if (suggestions.isEmpty()) emptyList() else listOf(Batch(key = 0, data = suggestions)) + val batchFlow: EarnTokensBatchFlow = mockk { + every { state } returns MutableStateFlow(BatchListState(data = batches, status = status)) + } + every { getEarnTokensBatchFlowUseCase(any(), any()) } returns batchFlow + } + + /** A yield-type suggestion (7.5% on Solana) the user does not hold yet. */ + private fun createTopEarnSuggestion(): EarnTokenWithCurrency = EarnTokenWithCurrency( + networkName = "Solana", + earnToken = EarnToken( + apy = "7.5", + networkId = "solana", + rewardType = EarnRewardType.APY, + type = EarnType.YIELD, + tokenId = "solana", + tokenSymbol = "SOL", + tokenName = "Solana", + tokenAddress = null, + decimalCount = null, + ), + cryptoCurrency = createCoin( + rawCurrencyId = "solana", + symbol = "SOL", + name = "Solana", + networkRawId = "solana", + decimals = 9, + ), + ) + private fun createModel(testScope: TestScope): ForYouModel { return ForYouModel( paramsContainer = MutableParamsContainer( @@ -210,8 +348,13 @@ internal class ForYouModelTest { ), userWalletsListRepository = userWalletsListRepository, multiAccountStatusListSupplier = multiAccountStatusListSupplier, + yieldSupplyApyFlowUseCase = yieldSupplyApyFlowUseCase, dispatchers = testScope.createTestingCoroutineDispatcherProvider(), getSelectedAppCurrencyUseCase = getSelectedAppCurrencyUseCase, + getEarnTokensBatchFlowUseCase = getEarnTokensBatchFlowUseCase, + stakingAvailabilityListUseCase = stakingAvailabilityListUseCase, + isAccountsModeEnabledUseCase = isAccountsModeEnabledUseCase, + earnErrorResolver = earnErrorResolver, ).also { model = it } } @@ -228,14 +371,20 @@ internal class ForYouModelTest { private fun createAccountStatusList( currencies: List, - totalFiatBalance: BigDecimal, source: StatusSource = StatusSource.ACTUAL, ): AccountStatusList = mockk { every { flattenCurrencies() } returns currencies every { this@mockk.totalFiatBalance } returns TotalFiatBalance.Loaded( - amount = totalFiatBalance, + amount = currencies.sumOf { it.value.fiatAmount ?: BigDecimal.ZERO }, source = source, ) + every { userWalletId } returns WALLET_ID + every { accountStatuses } returns listOf( + mockk { + every { flattenCurrencies() } returns currencies + every { account } returns MockAccounts.createAccount(derivationIndex = 1) + }, + ) } private fun createStatus(currency: CryptoCurrency, value: CryptoCurrencyStatus.Value) = CryptoCurrencyStatus( @@ -248,14 +397,18 @@ internal class ForYouModelTest { every { this@mockk.fiatAmount } returns fiatAmount every { isError } returns false every { sources } returns CryptoCurrencyStatus.Sources() + every { yieldSupplyStatus } returns null + every { stakingBalance } returns null } - private fun createCoin(rawCurrencyId: String, symbol: String): CryptoCurrency.Coin { - val network: Network = mockk { - every { name } returns "Network" - every { isTestnet } returns false - every { id } returns mockk { every { rawId } returns Network.RawID(rawCurrencyId) } - } + private fun createCoin( + rawCurrencyId: String, + symbol: String, + name: String = symbol, + networkRawId: String = rawCurrencyId, + decimals: Int = 8, + ): CryptoCurrency.Coin { + val network = createNetwork(networkRawId) val currencyId: CryptoCurrency.ID = mockk { every { value } returns "coin-$rawCurrencyId" every { this@mockk.rawCurrencyId } returns CryptoCurrency.RawID(rawCurrencyId) @@ -263,11 +416,31 @@ internal class ForYouModelTest { return mockk { every { this@mockk.id } returns currencyId every { this@mockk.symbol } returns symbol - every { this@mockk.name } returns symbol + every { this@mockk.name } returns name every { this@mockk.network } returns network - every { this@mockk.decimals } returns 8 + every { this@mockk.decimals } returns decimals every { isCustom } returns false every { iconUrl } returns null } } + + private fun createNetwork(networkRawId: String): Network { + val networkId: Network.ID = mockk { + every { rawId } returns Network.RawID(networkRawId) + } + val networkStandardType: Network.StandardType = mockk { + every { name } returns "ERC20" + } + return mockk { + every { name } returns "Network" + every { isTestnet } returns false + every { rawId } returns networkRawId + every { id } returns networkId + every { standardType } returns networkStandardType + } + } + + private companion object { + val WALLET_ID = UserWalletId("01") + } } \ No newline at end of file diff --git a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/ForYouTokenListConverterTest.kt b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/ForYouTokenListConverterTest.kt deleted file mode 100644 index 192e969293..0000000000 --- a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/ForYouTokenListConverterTest.kt +++ /dev/null @@ -1,300 +0,0 @@ -package com.tangem.features.foryou.impl.model.converter - -import com.google.common.truth.Truth.assertThat -import com.tangem.core.ui.ds.row.token.TangemTokenRowUM -import com.tangem.core.ui.extensions.pluralReference -import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.extensions.stringReference -import com.tangem.core.ui.extensions.wrappedList -import com.tangem.domain.appcurrency.model.AppCurrency -import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.domain.models.network.Network -import com.tangem.features.foryou.impl.R -import io.mockk.every -import io.mockk.mockk -import org.junit.jupiter.api.Nested -import org.junit.jupiter.api.Test -import java.math.BigDecimal - -internal class ForYouTokenListConverterTest { - - private val appCurrency: AppCurrency = AppCurrency.Default - - @Nested - inner class Convert { - - @Test - fun `GIVEN single-network coin WHEN convert THEN subtitle is common main network`() { - // Arrange - val currency = createCoin(rawCurrencyId = "bitcoin", symbol = "BTC", networkId = "bitcoin") - val status = createStatus(currency, loadedValue(BigDecimal("1"), BigDecimal("100"))) - val converter = createConverter(totalFiatBalance = BigDecimal("100")) - - // Act - val result = converter.convert(listOf(status)) - - // Assert - val row = result.single().tokenRowUM as TangemTokenRowUM.Content - val subtitle = row.subtitleUM as TangemTokenRowUM.SubtitleUM.Content - assertThat(subtitle.text).isEqualTo(resourceReference(R.string.common_main_network)) - } - - @Test - fun `GIVEN single-network token WHEN convert THEN subtitle is the network standard type name`() { - // Arrange - val currency = createToken( - rawCurrencyId = "usdc", - symbol = "USDC", - networkId = "ethereum", - standardTypeName = "ERC20", - ) - val status = createStatus(currency, loadedValue(BigDecimal("1"), BigDecimal("100"))) - val converter = createConverter(totalFiatBalance = BigDecimal("100")) - - // Act - val result = converter.convert(listOf(status)) - - // Assert - val row = result.single().tokenRowUM as TangemTokenRowUM.Content - val subtitle = row.subtitleUM as TangemTokenRowUM.SubtitleUM.Content - assertThat(subtitle.text).isEqualTo(stringReference("ERC20")) - } - - @Test - fun `GIVEN asset spans multiple networks WHEN convert THEN subtitle shows network count`() { - // Arrange — same asset (shared rawCurrencyId) on two different networks - val onEth = createToken(rawCurrencyId = "usdc", symbol = "USDC", networkId = "ethereum") - val onSol = createToken(rawCurrencyId = "usdc", symbol = "USDC", networkId = "solana") - val statusEth = createStatus(onEth, loadedValue(BigDecimal("1"), BigDecimal("100"))) - val statusSol = createStatus(onSol, loadedValue(BigDecimal("2"), BigDecimal("200"))) - val converter = createConverter( - totalFiatBalance = BigDecimal("300"), - ) - - // Act - val result = converter.convert(listOf(statusEth, statusSol)) - - // Assert - val item = result.single() - val row = item.tokenRowUM as TangemTokenRowUM.Content - val subtitle = row.subtitleUM as TangemTokenRowUM.SubtitleUM.Content - assertThat(subtitle.text).isEqualTo(pluralReference(R.plurals.common_networks_count, count = 2)) - assertThat(item.tokenList).hasSize(2) - } - - @Test - fun `GIVEN multi-network asset WHEN convert THEN child rows ordered by descending fiat balance`() { - // Arrange - val onEth = createToken(rawCurrencyId = "usdc", symbol = "USDC", networkId = "ethereum") - val onSol = createToken(rawCurrencyId = "usdc", symbol = "USDC", networkId = "solana") - val statusEth = createStatus(onEth, loadedValue(BigDecimal("1"), BigDecimal("100"))) - val statusSol = createStatus(onSol, loadedValue(BigDecimal("2"), BigDecimal("500"))) - val converter = createConverter( - totalFiatBalance = BigDecimal("600"), - ) - - // Act - val result = converter.convert(listOf(statusEth, statusSol)) - - // Assert — Solana holding (500) ranks above Ethereum holding (100) - val childIds = result.single().tokenList.map { it.id } - assertThat(childIds).containsExactly("token-usdc-solana", "token-usdc-ethereum").inOrder() - } - - @Test - fun `GIVEN all statuses of an asset are Loading WHEN convert THEN asset row is Loading`() { - // Arrange - val currency = createCoin(rawCurrencyId = "bitcoin", symbol = "BTC", networkId = "bitcoin") - val status = createStatus(currency, CryptoCurrencyStatus.Loading) - val converter = createConverter(totalFiatBalance = BigDecimal.ZERO) - - // Act - val result = converter.convert(listOf(status)) - - // Assert - assertThat(result.single().tokenRowUM).isInstanceOf(TangemTokenRowUM.Loading::class.java) - } - - @Test - fun `GIVEN no other assets WHEN convert THEN no Other row is appended`() { - // Arrange - val currency = createCoin(rawCurrencyId = "bitcoin", symbol = "BTC", networkId = "bitcoin") - val status = createStatus(currency, loadedValue(BigDecimal("1"), BigDecimal("100"))) - val converter = createConverter( - totalFiatBalance = BigDecimal("100"), - otherAssets = emptyList(), - ) - - // Act - val result = converter.convert(listOf(status)) - - // Assert - assertThat(result).hasSize(1) - } - - @Test - fun `GIVEN a single other asset WHEN convert THEN Other row subtitle is singular`() { - // Arrange - val currency = createCoin(rawCurrencyId = "bitcoin", symbol = "BTC", networkId = "bitcoin") - val status = createStatus(currency, loadedValue(BigDecimal("1"), BigDecimal("100"))) - val converter = createConverter( - totalFiatBalance = BigDecimal("100"), - otherAssets = listOf(otherAsset(BigDecimal("50"))), - ) - - // Act - val result = converter.convert(listOf(status)) - - // Assert - val otherRow = result.last().tokenRowUM as TangemTokenRowUM.Content - assertThat(otherRow.id).isEqualTo("for_you_other_assets") - val subtitle = otherRow.subtitleUM as TangemTokenRowUM.SubtitleUM.Content - assertThat(subtitle.text).isEqualTo( - pluralReference(R.plurals.market_chart_assets_android, count = 1, formatArgs = wrappedList(1)), - ) - } - - @Test - fun `GIVEN more than one other asset WHEN convert THEN Other row subtitle is plural`() { - // Arrange - val currency = createCoin(rawCurrencyId = "bitcoin", symbol = "BTC", networkId = "bitcoin") - val status = createStatus(currency, loadedValue(BigDecimal("1"), BigDecimal("100"))) - val converter = createConverter( - totalFiatBalance = BigDecimal("100"), - otherAssets = listOf( - otherAsset(BigDecimal("30")), - otherAsset(BigDecimal("15")), - otherAsset(BigDecimal("5")), - ), - ) - - // Act - val result = converter.convert(listOf(status)) - - // Assert - val otherRow = result.last().tokenRowUM as TangemTokenRowUM.Content - val subtitle = otherRow.subtitleUM as TangemTokenRowUM.SubtitleUM.Content - assertThat(subtitle.text).isEqualTo( - pluralReference(R.plurals.market_chart_assets_android, count = 3, formatArgs = wrappedList(3)), - ) - } - - @Test - fun `GIVEN asset id in expandedAssetIds WHEN convert THEN item isExpanded is true`() { - // Arrange - val currency = createCoin(rawCurrencyId = "bitcoin", symbol = "BTC", networkId = "bitcoin") - val status = createStatus(currency, loadedValue(BigDecimal("1"), BigDecimal("100"))) - val converter = createConverter( - totalFiatBalance = BigDecimal("100"), - expandedAssetIds = setOf("bitcoin"), - ) - - // Act - val result = converter.convert(listOf(status)) - - // Assert - assertThat(result.single().isExpanded).isTrue() - } - - @Test - fun `GIVEN asset id not in expandedAssetIds WHEN convert THEN item isExpanded is false`() { - // Arrange - val currency = createCoin(rawCurrencyId = "bitcoin", symbol = "BTC", networkId = "bitcoin") - val status = createStatus(currency, loadedValue(BigDecimal("1"), BigDecimal("100"))) - val converter = createConverter( - totalFiatBalance = BigDecimal("100"), - expandedAssetIds = emptySet(), - ) - - // Act - val result = converter.convert(listOf(status)) - - // Assert - assertThat(result.single().isExpanded).isFalse() - } - } - - private fun createConverter( - totalFiatBalance: BigDecimal, - expandedAssetIds: Set = emptySet(), - otherAssets: List, BigDecimal>> = emptyList(), - ): ForYouTokenListConverter = ForYouTokenListConverter( - appCurrency = appCurrency, - totalFiatBalance = totalFiatBalance, - expandedAssetIds = expandedAssetIds, - expandClick = {}, - otherAssets = otherAssets, - onTokenClick = {}, - ) - - /** - * Builds an "other" asset entry — only its summed [balance] and the number of entries drive the - * collapsed "Other" row, so the currency list is left empty. - */ - private fun otherAsset(balance: BigDecimal): Pair, BigDecimal> = - emptyList() to balance - - private fun createStatus(currency: CryptoCurrency, value: CryptoCurrencyStatus.Value) = CryptoCurrencyStatus( - currency = currency, - value = value, - ) - - private fun loadedValue(amount: BigDecimal, fiatAmount: BigDecimal): CryptoCurrencyStatus.Loaded = mockk { - every { this@mockk.amount } returns amount - every { this@mockk.fiatAmount } returns fiatAmount - every { isError } returns false - every { sources } returns CryptoCurrencyStatus.Sources() - } - - private fun createCoin(rawCurrencyId: String, symbol: String, networkId: String): CryptoCurrency.Coin { - val network = createNetwork(networkId = networkId, standardTypeName = "MAIN") - val currencyId = createCurrencyId(idValue = "coin-$rawCurrencyId-$networkId", rawCurrencyId = rawCurrencyId) - return mockk { - every { this@mockk.id } returns currencyId - every { this@mockk.symbol } returns symbol - every { this@mockk.network } returns network - every { this@mockk.decimals } returns 8 - every { isCustom } returns false - every { iconUrl } returns null - } - } - - private fun createToken( - rawCurrencyId: String, - symbol: String, - networkId: String, - standardTypeName: String = "ERC20", - ): CryptoCurrency.Token { - val network = createNetwork(networkId = networkId, standardTypeName = standardTypeName) - val currencyId = createCurrencyId(idValue = "token-$rawCurrencyId-$networkId", rawCurrencyId = rawCurrencyId) - return mockk { - every { this@mockk.id } returns currencyId - every { this@mockk.symbol } returns symbol - every { this@mockk.network } returns network - every { this@mockk.decimals } returns 6 - every { isCustom } returns false - every { iconUrl } returns null - every { contractAddress } returns "0xCONTRACT" - } - } - - private fun createCurrencyId(idValue: String, rawCurrencyId: String): CryptoCurrency.ID = mockk { - every { value } returns idValue - every { this@mockk.rawCurrencyId } returns CryptoCurrency.RawID(rawCurrencyId) - } - - private fun createNetwork(networkId: String, standardTypeName: String): Network { - val standardType: Network.StandardType = mockk { - every { name } returns standardTypeName - } - return mockk { - every { id } returns mockk { - every { rawId } returns Network.RawID(networkId) - } - every { name } returns networkId - every { isTestnet } returns false - every { this@mockk.standardType } returns standardType - } - } -} \ No newline at end of file diff --git a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/ForYouPortfolioFormattersTest.kt b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/ForYouUtilsTest.kt similarity index 99% rename from features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/ForYouPortfolioFormattersTest.kt rename to features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/ForYouUtilsTest.kt index 1766aa4a97..114b1accbe 100644 --- a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/ForYouPortfolioFormattersTest.kt +++ b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/ForYouUtilsTest.kt @@ -10,7 +10,7 @@ import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test import java.math.BigDecimal -internal class ForYouPortfolioFormattersTest { +internal class ForYouUtilsTest { @Nested inner class ForYouGroupKey { diff --git a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/EarnOpportunitiesTestFactory.kt b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/EarnOpportunitiesTestFactory.kt index ec6c995036..9bbc02f746 100644 --- a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/EarnOpportunitiesTestFactory.kt +++ b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/EarnOpportunitiesTestFactory.kt @@ -148,12 +148,13 @@ internal fun createEarnStatusValue( fiatAmount: BigDecimal = BigDecimal("100"), yieldSupplyActive: Boolean? = null, isStakingActive: Boolean = false, + stakingBalance: StakingBalance? = if (isStakingActive) mockk() else null, ): CryptoCurrencyStatus.Loaded = mockk { every { this@mockk.fiatAmount } returns fiatAmount every { yieldSupplyStatus } returns yieldSupplyActive?.let { active -> mockk { every { isActive } returns active } } - every { stakingBalance } returns if (isStakingActive) mockk() else null + every { this@mockk.stakingBalance } returns stakingBalance every { isError } returns false every { sources } returns CryptoCurrencyStatus.Sources() } @@ -165,13 +166,13 @@ internal fun createStatus( internal fun createEarnOpportunities( account: Account.CryptoPortfolio = MockAccounts.createAccount(derivationIndex = 1), - earnCurrencues: Map = mapOf( + earnCurrencies: Map = mapOf( createStatus(createEarnCurrency()) to createEarnApyInfo(), ), accountPotentialReward: BigDecimal = BigDecimal.ZERO, ): EarnOpportunities = EarnOpportunities( account = account, - earnCurrencues = earnCurrencues, + earnCurrencies = earnCurrencies, accountPotentialReward = accountPotentialReward, ) diff --git a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesConverterTest.kt b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesConverterTest.kt index e38e2b23dc..25a6c7a14d 100644 --- a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesConverterTest.kt +++ b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesConverterTest.kt @@ -2,17 +2,27 @@ package com.tangem.features.foryou.impl.model.converter.earnOpportunities import com.google.common.truth.Truth.assertThat import com.tangem.common.ui.R +import com.tangem.core.ui.ds.row.token.TangemTokenRowUM +import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.format.bigdecimal.fiat import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.format.bigdecimal.percent import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.yieldSupplyKey import com.tangem.domain.models.earn.EarnTopToken +import com.tangem.domain.models.staking.BalanceItem +import com.tangem.domain.models.staking.StakingBalance import com.tangem.domain.staking.model.StakingAvailability +import com.tangem.domain.staking.model.StakingIntegrationID import com.tangem.domain.staking.model.StakingOption +import com.tangem.domain.staking.model.common.RewardInfo +import com.tangem.domain.staking.model.common.RewardType +import com.tangem.domain.staking.model.stakekit.Yield import com.tangem.features.foryou.impl.entity.EarnOpportunitiesUM +import com.tangem.test.mock.MockAccounts import io.mockk.every import io.mockk.mockk import org.junit.jupiter.api.Nested @@ -193,6 +203,74 @@ internal class ForYouEarnOpportunitiesConverterTest { .isEqualTo(expectedPerYearReward(fiat = BigDecimal("100"), yieldPercent = BigDecimal("10.00"))) } + @Test + fun `GIVEN active StakeKit stake WHEN convert THEN the staked validator's rate wins over best preferred`() { + // Arrange — the user stakes with v2 (4%), while the best preferred validator offers 10% + val staked = createEarnCurrency(tokenId = "ethereum", currencyId = "coin-staked") + val stakedStatus = createStatus( + staked, + createEarnStatusValue(fiatAmount = BigDecimal("100"), stakingBalance = stakeKitBalance("v2")), + ) + val fresh = createEarnCurrency(tokenId = "solana", currencyId = "coin-fresh") + val freshStatus = createStatus(fresh, createEarnStatusValue(fiatAmount = BigDecimal("100"))) + val converter = createConverter( + yieldStakingAvailability = mapOf( + staked to stakeKitAvailable( + validator(address = "v1", preferred = true, rate = BigDecimal("0.10")), + validator(address = "v2", preferred = false, rate = BigDecimal("0.04")), + ), + fresh to stakingAvailable(apy = BigDecimal("0.05")), + ), + ) + + // Act + val result = converter.convert( + createAccountStatusList(createPortfolioStatus(listOf(stakedStatus, freshStatus))), + ) + + // Assert — the staked token's row shows the 4% of the validator actually staked with + assertThat((result as EarnOpportunitiesUM.Content).rateOfRow("coin-staked")) + .isEqualTo(BigDecimal("0.04").format { percent() }) + } + + @Test + fun `GIVEN stake with unknown validator WHEN convert THEN falls back to best preferred validator rate`() { + // Arrange — the staked validator is not among the option's validators + val staked = createEarnCurrency(tokenId = "ethereum", currencyId = "coin-staked") + val stakedStatus = createStatus( + staked, + createEarnStatusValue(fiatAmount = BigDecimal("100"), stakingBalance = stakeKitBalance("unknown")), + ) + val fresh = createEarnCurrency(tokenId = "solana", currencyId = "coin-fresh") + val freshStatus = createStatus(fresh, createEarnStatusValue(fiatAmount = BigDecimal("100"))) + val converter = createConverter( + yieldStakingAvailability = mapOf( + staked to stakeKitAvailable( + validator(address = "v1", preferred = true, rate = BigDecimal("0.10")), + validator(address = "v2", preferred = true, rate = BigDecimal("0.12")), + validator(address = "v3", preferred = false, rate = BigDecimal("0.50")), + ), + fresh to stakingAvailable(apy = BigDecimal("0.05")), + ), + ) + + // Act + val result = converter.convert( + createAccountStatusList(createPortfolioStatus(listOf(stakedStatus, freshStatus))), + ) + + // Assert — the best *preferred* rate (12%) is used; the non-preferred 50% is ignored + assertThat((result as EarnOpportunitiesUM.Content).rateOfRow("coin-staked")) + .isEqualTo(BigDecimal("0.12").format { percent() }) + } + + /** Extracts the rendered rate (the styled bottom-end text) of the row with the given [id]. */ + private fun EarnOpportunitiesUM.Content.rateOfRow(id: String): String { + val row = tokenList.first { it.tokenRowUM.id == id }.tokenRowUM as TangemTokenRowUM.Content + val bottomEnd = row.bottomEndContentUM as TangemTokenRowUM.EndContentUM.Content + return (bottomEnd.text as TextReference.StyledStr).value + } + @Test fun `GIVEN staking-only token WHEN convert THEN staking rate is used for the reward`() { // Arrange @@ -212,6 +290,39 @@ internal class ForYouEarnOpportunitiesConverterTest { } } + @Nested + inner class AccountOrdering { + + @Test + fun `GIVEN several accounts WHEN convert THEN accounts ordered by potential reward descending`() { + // Arrange — same 5% rate; the second account holds more fiat (200 vs 100), so it earns more + val smallHolding = createEarnCurrency(tokenId = "ethereum", currencyId = "coin-eth") + val largeHolding = createEarnCurrency(tokenId = "solana", currencyId = "coin-sol") + val smallAccount = createPortfolioStatus( + currencies = listOf(createStatus(smallHolding, createEarnStatusValue(fiatAmount = BigDecimal("100")))), + account = MockAccounts.createAccount(derivationIndex = 1), + ) + val largeAccount = createPortfolioStatus( + currencies = listOf(createStatus(largeHolding, createEarnStatusValue(fiatAmount = BigDecimal("200")))), + account = MockAccounts.createAccount(derivationIndex = 2), + ) + val converter = createConverter( + yieldStakingAvailability = mapOf( + smallHolding to stakingAvailable(apy = BigDecimal("0.05")), + largeHolding to stakingAvailable(apy = BigDecimal("0.05")), + ), + ) + + // Act + val result = converter.convert(createAccountStatusList(smallAccount, largeAccount)) + + // Assert — the higher-earning account's token leads the flat list + assertThat((result as EarnOpportunitiesUM.Content).tokenList.map { it.tokenRowUM.id }) + .containsExactly("coin-sol", "coin-eth") + .inOrder() + } + } + private fun createConverter( yieldSupplyAvailability: Map = emptyMap(), yieldStakingAvailability: Map = emptyMap(), @@ -234,6 +345,39 @@ internal class ForYouEarnOpportunitiesConverterTest { private fun stakingAvailable(apy: BigDecimal): StakingAvailability = StakingAvailability.Available(option = stakingOption(apy)) + private fun stakeKitAvailable(vararg validatorList: Yield.Validator): StakingAvailability { + // A real StakeKit option with a real integration id: stubbing `integrationId` on a mock would + // make mockk instrument StakingIntegrationID.StakeKit, whose implementations are enums that the + // JVM refuses to retransform ("cannot change the class modifiers"). + val yieldModel: Yield = mockk { + every { validators } returns validatorList.toList() + every { apy } returns BigDecimal("0.10") + every { token } returns mockk() + every { isAvailable } returns true + } + return StakingAvailability.Available( + option = StakingOption.StakeKit( + integrationId = StakingIntegrationID.StakeKit.Coin.Ton, + yield = yieldModel, + ), + ) + } + + private fun validator(address: String, preferred: Boolean, rate: BigDecimal): Yield.Validator = mockk { + every { this@mockk.address } returns address + every { this@mockk.preferred } returns preferred + every { rewardInfo } returns RewardInfo(rate = rate, type = RewardType.APY) + } + + /** An active StakeKit balance whose items point at the given validator addresses. */ + private fun stakeKitBalance(vararg validatorAddresses: String?): StakingBalance.Data.StakeKit = mockk { + every { balance } returns mockk { + every { items } returns validatorAddresses.map { address -> + mockk { every { validatorAddress } returns address } + } + } + } + /** Mirrors the production reward computation: `fiat * (yieldPercent / 100)`, rendered per year. */ private fun expectedPerYearReward(fiat: BigDecimal, yieldPercent: BigDecimal) = fiat.multiply(yieldPercent.divide(BigDecimal("100"), RoundingMode.HALF_UP)).expectedPerYearText() diff --git a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesNoTokensConverterTest.kt b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesNoTokensConverterTest.kt index 614e819c4b..b34fbcc926 100644 --- a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesNoTokensConverterTest.kt +++ b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesNoTokensConverterTest.kt @@ -54,7 +54,9 @@ internal class ForYouEarnOpportunitiesNoTokensConverterTest { @Test fun `GIVEN no top tokens loaded WHEN convert THEN suggestions are empty and reward type is absent`() { // Arrange - val converter = ForYouEarnOpportunitiesNoTokensConverter(topEarnTokens = null) + val converter = ForYouEarnOpportunitiesNoTokensConverter( + topEarnTokens = null, + ) // Act val result = converter.convert(emptyList()) as EarnOpportunitiesUM.Content diff --git a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesPotentialRewardsConverterTest.kt b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesPotentialRewardsConverterTest.kt index 98cc7f3b16..32d1e4ed99 100644 --- a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesPotentialRewardsConverterTest.kt +++ b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesPotentialRewardsConverterTest.kt @@ -21,7 +21,7 @@ internal class ForYouEarnOpportunitiesPotentialRewardsConverterTest { fun `GIVEN accounts mode off WHEN convert THEN one flat row per earn currency`() { // Arrange val earnData = createEarnOpportunities( - earnCurrencues = listOf("token-a", "token-b").associate { currencyId -> + earnCurrencies = listOf("token-a", "token-b").associate { currencyId -> createStatus( createEarnCurrency(tokenId = currencyId, currencyId = currencyId), createRowLoadedValue(), @@ -45,7 +45,7 @@ internal class ForYouEarnOpportunitiesPotentialRewardsConverterTest { val account = MockAccounts.createAccount(derivationIndex = 1, name = "Earn account") val earnData = createEarnOpportunities( account = account, - earnCurrencues = listOf("token-a", "token-b").associate { currencyId -> + earnCurrencies = listOf("token-a", "token-b").associate { currencyId -> createStatus( createEarnCurrency(tokenId = currencyId, currencyId = currencyId), createRowLoadedValue(), @@ -71,7 +71,7 @@ internal class ForYouEarnOpportunitiesPotentialRewardsConverterTest { val account = MockAccounts.createAccount(derivationIndex = 1) val earnData = createEarnOpportunities( account = account, - earnCurrencues = mapOf( + earnCurrencies = mapOf( createStatus(createEarnCurrency(), createRowLoadedValue()) to createEarnApyInfo(isActive = false), ), ) @@ -93,7 +93,7 @@ internal class ForYouEarnOpportunitiesPotentialRewardsConverterTest { val account = MockAccounts.createAccount(derivationIndex = 1) val earnData = createEarnOpportunities( account = account, - earnCurrencues = mapOf( + earnCurrencies = mapOf( createStatus(createEarnCurrency(), createRowLoadedValue()) to createEarnApyInfo(isActive = false), ), ) diff --git a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTokensActiveConverterTest.kt b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTokensActiveConverterTest.kt index 6d5ff554ef..11341b1c94 100644 --- a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTokensActiveConverterTest.kt +++ b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTokensActiveConverterTest.kt @@ -15,7 +15,7 @@ internal class ForYouEarnOpportunitiesTokensActiveConverterTest { fun `GIVEN top tokens contain active portfolio assets WHEN convert THEN active ones are excluded`() { // Arrange val activePortfolio = createEarnOpportunities( - earnCurrencues = mapOf( + earnCurrencies = mapOf( createStatus(createEarnCurrency(tokenId = "ethereum", networkRawId = "ETH")) to createEarnApyInfo(), ), ) @@ -37,7 +37,7 @@ internal class ForYouEarnOpportunitiesTokensActiveConverterTest { fun `GIVEN more suggestions than the cap WHEN convert THEN filtering happens before the top-5 cut`() { // Arrange — two of the first candidates are active; the cap must still be filled from the tail val activePortfolio = createEarnOpportunities( - earnCurrencues = listOf("token-0", "token-1").associate { tokenId -> + earnCurrencies = listOf("token-0", "token-1").associate { tokenId -> createStatus(createEarnCurrency(tokenId = tokenId, networkRawId = "NET")) to createEarnApyInfo() }, ) @@ -60,7 +60,7 @@ internal class ForYouEarnOpportunitiesTokensActiveConverterTest { fun `GIVEN asset active on another network WHEN convert THEN suggestion on a new network is kept`() { // Arrange — matching is per asset AND network, not per asset val activePortfolio = createEarnOpportunities( - earnCurrencues = mapOf( + earnCurrencies = mapOf( createStatus(createEarnCurrency(tokenId = "usd-coin", networkRawId = "ETH")) to createEarnApyInfo(), ), ) @@ -81,7 +81,9 @@ internal class ForYouEarnOpportunitiesTokensActiveConverterTest { @Test fun `GIVEN no top tokens loaded WHEN convert THEN content with empty suggestions`() { // Arrange - val converter = ForYouEarnOpportunitiesTokensActiveConverter(topEarnTokens = null) + val converter = ForYouEarnOpportunitiesTokensActiveConverter( + topEarnTokens = null, + ) // Act val result = converter.convert(listOf(createEarnOpportunities())) diff --git a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/portfolioReview/ForYouPortfolioReviewConverterTest.kt b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/portfolioReview/ForYouPortfolioReviewConverterTest.kt new file mode 100644 index 0000000000..02e1a1dabc --- /dev/null +++ b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/portfolioReview/ForYouPortfolioReviewConverterTest.kt @@ -0,0 +1,458 @@ +package com.tangem.features.foryou.impl.model.converter.portfolioReview + +import com.google.common.truth.Truth.assertThat +import com.tangem.core.ui.ds.row.token.TangemTokenRowUM +import com.tangem.core.ui.extensions.pluralReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.domain.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.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.features.foryou.impl.R +import com.tangem.features.foryou.impl.components.state.MarketChartUM +import com.tangem.features.foryou.impl.entity.PortfolioReviewUM +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +internal class ForYouPortfolioReviewConverterTest { + + private val appCurrency: AppCurrency = AppCurrency.Default + + @Nested + inner class AssetRanking { + + @Test + fun `GIVEN currency with resolved zero fiat balance WHEN convert THEN it is dropped from the list`() { + // Arrange + val zeroBalance = createCoin(rawCurrencyId = "btc", symbol = "BTC", networkId = "bitcoin") + val nonZeroBalance = createCoin(rawCurrencyId = "eth", symbol = "ETH", networkId = "ethereum") + val statuses = listOf( + createStatus(zeroBalance, loadedValue(BigDecimal.ONE, BigDecimal.ZERO)), + createStatus(nonZeroBalance, loadedValue(BigDecimal.ONE, BigDecimal("100"))), + ) + + // Act + val result = convert(statuses, totalFiatBalance = BigDecimal("100")) + + // Assert — only the ETH asset survives; the zero-fiat BTC is dropped + assertThat(result.tokenList.map { it.tokenRowUM.id }).containsExactly("eth") + } + + @Test + fun `GIVEN non-content status with null fiat WHEN convert THEN it is kept not dropped`() { + // Arrange — a non-content status (Unreachable) carries a null fiatAmount, not a resolved zero; + // it must still be shown so the user sees the token they hold, with the appropriate treatment. + val unreachable = createCoin(rawCurrencyId = "btc", symbol = "BTC", networkId = "bitcoin") + val loaded = createCoin(rawCurrencyId = "eth", symbol = "ETH", networkId = "ethereum") + val statuses = listOf( + createStatus(unreachable, unreachableValue()), + createStatus(loaded, loadedValue(BigDecimal.ONE, BigDecimal("100"))), + ) + + // Act + val result = convert(statuses, totalFiatBalance = BigDecimal("100")) + + // Assert — both assets kept, ranked by summed fiat (eth 100 > btc 0) + assertThat(result.tokenList.map { it.tokenRowUM.id }).containsExactly("eth", "btc").inOrder() + } + + @Test + fun `GIVEN same asset across networks WHEN convert THEN aggregated into one asset ranked by summed fiat`() { + // Arrange — the same asset (shared rawCurrencyId "usdc") aggregates into one asset + val onEth = createToken(rawCurrencyId = "usdc", symbol = "USDC", networkId = "ethereum") + val onSol = createToken(rawCurrencyId = "usdc", symbol = "USDC", networkId = "solana") + val other = createCoin(rawCurrencyId = "btc", symbol = "BTC", networkId = "bitcoin") + val statuses = listOf( + createStatus(onEth, loadedValue(BigDecimal.ONE, BigDecimal("50"))), + createStatus(onSol, loadedValue(BigDecimal.ONE, BigDecimal("60"))), + createStatus(other, loadedValue(BigDecimal.ONE, BigDecimal("10"))), + ) + + // Act + val result = convert(statuses, totalFiatBalance = BigDecimal("120")) + + // Assert — 2 ranked assets: usdc (110 total) ahead of btc (10) + assertThat(result.tokenList.map { it.tokenRowUM.id }).containsExactly("usdc", "btc").inOrder() + } + + @Test + fun `GIVEN more than four assets WHEN convert THEN excess assets collapse into Other`() { + // Arrange — 5 distinct assets, top 4 kept individually, 5th collapsed into "Other" + val statuses = (1..5).map { index -> + createStatus( + createCoin(rawCurrencyId = "asset-$index", symbol = "A$index", networkId = "net-$index"), + loadedValue(BigDecimal.ONE, BigDecimal(100 - index)), + ) + } + + // Act + val result = convert(statuses, totalFiatBalance = BigDecimal("470")) + + // Assert — 4 top asset rows + 1 "Other" row + assertThat(result.tokenList).hasSize(5) + assertThat(result.tokenList.last().tokenRowUM.id).isEqualTo("for_you_other_assets") + assertThat(result.tokenList.last().isExpandable).isFalse() + } + + @Test + fun `GIVEN exactly four assets WHEN convert THEN no Other row is appended`() { + // Arrange + val statuses = (1..4).map { index -> + createStatus( + createCoin(rawCurrencyId = "asset-$index", symbol = "A$index", networkId = "net-$index"), + loadedValue(BigDecimal.ONE, BigDecimal(100 - index)), + ) + } + + // Act + val result = convert(statuses, totalFiatBalance = BigDecimal("394")) + + // Assert + assertThat(result.tokenList).hasSize(4) + } + + @Test + fun `GIVEN a single other asset WHEN convert THEN Other row subtitle is singular`() { + // Arrange — 5 assets: the lowest-balance one collapses into an "Other" row of count 1 + val statuses = (1..5).map { index -> + createStatus( + createCoin(rawCurrencyId = "asset-$index", symbol = "A$index", networkId = "net-$index"), + loadedValue(BigDecimal.ONE, BigDecimal(100 - index)), + ) + } + + // Act + val result = convert(statuses, totalFiatBalance = BigDecimal("470")) + + // Assert + val otherRow = result.tokenList.last().tokenRowUM as TangemTokenRowUM.Content + val subtitle = otherRow.subtitleUM as TangemTokenRowUM.SubtitleUM.Content + assertThat(subtitle.text).isEqualTo( + pluralReference(R.plurals.market_chart_assets_android, count = 1, formatArgs = wrappedList(1)), + ) + } + + @Test + fun `GIVEN several other assets WHEN convert THEN Other row subtitle is plural`() { + // Arrange — 7 assets: three lowest-balance ones collapse into an "Other" row of count 3 + val statuses = (1..7).map { index -> + createStatus( + createCoin(rawCurrencyId = "asset-$index", symbol = "A$index", networkId = "net-$index"), + loadedValue(BigDecimal.ONE, BigDecimal(100 - index)), + ) + } + + // Act + val result = convert(statuses, totalFiatBalance = BigDecimal("658")) + + // Assert + val otherRow = result.tokenList.last().tokenRowUM as TangemTokenRowUM.Content + val subtitle = otherRow.subtitleUM as TangemTokenRowUM.SubtitleUM.Content + assertThat(subtitle.text).isEqualTo( + pluralReference(R.plurals.market_chart_assets_android, count = 3, formatArgs = wrappedList(3)), + ) + } + + @Test + fun `GIVEN null account status list WHEN convert THEN token list is empty`() { + // Act + val result = createConverter().convert(null) as PortfolioReviewUM.Content + + // Assert + assertThat(result.tokenList).isEmpty() + } + } + + @Nested + inner class AssetRow { + + @Test + fun `GIVEN single-network coin WHEN convert THEN subtitle is common main network`() { + // Arrange + val currency = createCoin(rawCurrencyId = "bitcoin", symbol = "BTC", networkId = "bitcoin") + val statuses = listOf(createStatus(currency, loadedValue(BigDecimal.ONE, BigDecimal("100")))) + + // Act + val result = convert(statuses, totalFiatBalance = BigDecimal("100")) + + // Assert + val row = result.tokenList.single().tokenRowUM as TangemTokenRowUM.Content + val subtitle = row.subtitleUM as TangemTokenRowUM.SubtitleUM.Content + assertThat(subtitle.text).isEqualTo(resourceReference(R.string.common_main_network)) + } + + @Test + fun `GIVEN single-network token WHEN convert THEN subtitle is the network standard type name`() { + // Arrange + val currency = createToken( + rawCurrencyId = "usdc", + symbol = "USDC", + networkId = "ethereum", + standardTypeName = "ERC20", + ) + val statuses = listOf(createStatus(currency, loadedValue(BigDecimal.ONE, BigDecimal("100")))) + + // Act + val result = convert(statuses, totalFiatBalance = BigDecimal("100")) + + // Assert + val row = result.tokenList.single().tokenRowUM as TangemTokenRowUM.Content + val subtitle = row.subtitleUM as TangemTokenRowUM.SubtitleUM.Content + assertThat(subtitle.text).isEqualTo(stringReference("ERC20")) + } + + @Test + fun `GIVEN asset spans multiple networks WHEN convert THEN subtitle shows network count with child rows`() { + // Arrange — same asset (shared rawCurrencyId) on two different networks + val onEth = createToken(rawCurrencyId = "usdc", symbol = "USDC", networkId = "ethereum") + val onSol = createToken(rawCurrencyId = "usdc", symbol = "USDC", networkId = "solana") + val statuses = listOf( + createStatus(onEth, loadedValue(BigDecimal.ONE, BigDecimal("100"))), + createStatus(onSol, loadedValue(BigDecimal("2"), BigDecimal("200"))), + ) + + // Act + val result = convert(statuses, totalFiatBalance = BigDecimal("300")) + + // Assert + val item = result.tokenList.single() + val row = item.tokenRowUM as TangemTokenRowUM.Content + val subtitle = row.subtitleUM as TangemTokenRowUM.SubtitleUM.Content + assertThat(subtitle.text).isEqualTo(pluralReference(R.plurals.common_networks_count, count = 2)) + assertThat(item.tokenList).hasSize(2) + } + + @Test + fun `GIVEN multi-network asset WHEN convert THEN child rows ordered by descending fiat balance`() { + // Arrange + val onEth = createToken(rawCurrencyId = "usdc", symbol = "USDC", networkId = "ethereum") + val onSol = createToken(rawCurrencyId = "usdc", symbol = "USDC", networkId = "solana") + val statuses = listOf( + createStatus(onEth, loadedValue(BigDecimal.ONE, BigDecimal("100"))), + createStatus(onSol, loadedValue(BigDecimal("2"), BigDecimal("500"))), + ) + + // Act + val result = convert(statuses, totalFiatBalance = BigDecimal("600")) + + // Assert — Solana holding (500) ranks above Ethereum holding (100) + val childIds = result.tokenList.single().tokenList.map { it.id } + assertThat(childIds).containsExactly("token-usdc-solana", "token-usdc-ethereum").inOrder() + } + + @Test + fun `GIVEN all statuses of an asset are Loading WHEN convert THEN asset row is Loading`() { + // Arrange + val currency = createCoin(rawCurrencyId = "bitcoin", symbol = "BTC", networkId = "bitcoin") + val statuses = listOf(createStatus(currency, CryptoCurrencyStatus.Loading)) + + // Act + val result = convert(statuses, totalFiatBalance = BigDecimal.ZERO) + + // Assert + assertThat(result.tokenList.single().tokenRowUM).isInstanceOf(TangemTokenRowUM.Loading::class.java) + } + + @Test + fun `GIVEN asset id in expandedAssetIds WHEN convert THEN item isExpanded is true`() { + // Arrange + val currency = createCoin(rawCurrencyId = "bitcoin", symbol = "BTC", networkId = "bitcoin") + val statuses = listOf(createStatus(currency, loadedValue(BigDecimal.ONE, BigDecimal("100")))) + val converter = createConverter(expandedAssetIds = setOf("bitcoin")) + + // Act + val result = converter.convert( + accountStatusList(statuses, BigDecimal("100")), + ) as PortfolioReviewUM.Content + + // Assert + assertThat(result.tokenList.single().isExpanded).isTrue() + } + + @Test + fun `GIVEN asset row clicked WHEN convert THEN expand callback receives the asset id`() { + // Arrange + val currency = createCoin(rawCurrencyId = "bitcoin", symbol = "BTC", networkId = "bitcoin") + val statuses = listOf(createStatus(currency, loadedValue(BigDecimal.ONE, BigDecimal("100")))) + var clickedAssetId: String? = null + val converter = createConverter(expandClick = { clickedAssetId = it }) + + // Act + val result = converter.convert( + accountStatusList(statuses, BigDecimal("100")), + ) as PortfolioReviewUM.Content + (result.tokenList.single().tokenRowUM as TangemTokenRowUM.Content).onItemClick?.invoke() + + // Assert + assertThat(clickedAssetId).isEqualTo("bitcoin") + } + } + + @Nested + inner class MarketChart { + + @Test + fun `GIVEN loaded total balance WHEN convert THEN market chart is Loaded with one segment per top asset`() { + // Arrange + val statuses = listOf( + createStatus( + createCoin(rawCurrencyId = "btc", symbol = "BTC", networkId = "bitcoin"), + loadedValue(BigDecimal.ONE, BigDecimal("70")), + ), + createStatus( + createCoin(rawCurrencyId = "eth", symbol = "ETH", networkId = "ethereum"), + loadedValue(BigDecimal.ONE, BigDecimal("30")), + ), + ) + + // Act + val result = convert(statuses, totalFiatBalance = BigDecimal("100")) + + // Assert + val marketChart = result.marketChartUM as MarketChartUM.Loaded + assertThat(marketChart.assetCount).isEqualTo(2) + } + + @Test + fun `GIVEN non-loaded total balance WHEN convert THEN market chart is NoData`() { + // Arrange + val statuses = listOf( + createStatus( + createCoin(rawCurrencyId = "btc", symbol = "BTC", networkId = "bitcoin"), + loadedValue(BigDecimal.ONE, BigDecimal("100")), + ), + ) + val statusList: AccountStatusList = mockk { + every { flattenCurrencies() } returns statuses + every { totalFiatBalance } returns TotalFiatBalance.Loading + every { userWalletId } returns UserWalletId("01") + } + + // Act + val result = createConverter().convert(statusList) as PortfolioReviewUM.Content + + // Assert + assertThat(result.marketChartUM).isEqualTo(MarketChartUM.NoData) + } + + @Test + fun `GIVEN null account status list WHEN convert THEN market chart is NoData`() { + // Act + val result = createConverter().convert(null) as PortfolioReviewUM.Content + + // Assert + assertThat(result.marketChartUM).isEqualTo(MarketChartUM.NoData) + } + } + + private fun convert( + statuses: List, + totalFiatBalance: BigDecimal, + ): PortfolioReviewUM.Content = + createConverter().convert(accountStatusList(statuses, totalFiatBalance)) as PortfolioReviewUM.Content + + private fun createConverter( + expandedAssetIds: Set = emptySet(), + expandClick: (String) -> Unit = {}, + onTokenClick: (UserWalletId, CryptoCurrency) -> Unit = { _, _ -> }, + ): ForYouPortfolioReviewConverter = ForYouPortfolioReviewConverter( + appCurrency = appCurrency, + expandedAssetIds = expandedAssetIds, + expandClick = expandClick, + onTokenClick = onTokenClick, + ) + + private fun accountStatusList( + currencies: List, + totalFiatBalance: BigDecimal, + source: StatusSource = StatusSource.ACTUAL, + ): AccountStatusList = mockk { + every { flattenCurrencies() } returns currencies + every { this@mockk.totalFiatBalance } returns TotalFiatBalance.Loaded( + amount = totalFiatBalance, + source = source, + ) + every { userWalletId } returns UserWalletId("01") + } + + private fun createStatus(currency: CryptoCurrency, value: CryptoCurrencyStatus.Value) = CryptoCurrencyStatus( + currency = currency, + value = value, + ) + + private fun loadedValue(amount: BigDecimal, fiatAmount: BigDecimal): CryptoCurrencyStatus.Loaded = mockk { + every { this@mockk.amount } returns amount + every { this@mockk.fiatAmount } returns fiatAmount + every { isError } returns false + every { sources } returns CryptoCurrencyStatus.Sources() + } + + /** A non-content status: carries a null fiatAmount (unknown balance), not a resolved zero. */ + private fun unreachableValue(): CryptoCurrencyStatus.Unreachable = CryptoCurrencyStatus.Unreachable( + priceChange = null, + fiatRate = null, + networkAddress = null, + ) + + private fun createCoin(rawCurrencyId: String, symbol: String, networkId: String): CryptoCurrency.Coin { + val network = createNetwork(networkId = networkId, standardTypeName = "MAIN") + val currencyId = createCurrencyId(idValue = "coin-$rawCurrencyId-$networkId", rawCurrencyId = rawCurrencyId) + return mockk { + every { this@mockk.id } returns currencyId + every { this@mockk.symbol } returns symbol + every { this@mockk.name } returns symbol + every { this@mockk.network } returns network + every { this@mockk.decimals } returns 8 + every { isCustom } returns false + every { iconUrl } returns null + } + } + + private fun createToken( + rawCurrencyId: String, + symbol: String, + networkId: String, + standardTypeName: String = "ERC20", + ): CryptoCurrency.Token { + val network = createNetwork(networkId = networkId, standardTypeName = standardTypeName) + val currencyId = createCurrencyId(idValue = "token-$rawCurrencyId-$networkId", rawCurrencyId = rawCurrencyId) + return mockk { + every { this@mockk.id } returns currencyId + every { this@mockk.symbol } returns symbol + every { this@mockk.name } returns symbol + every { this@mockk.network } returns network + every { this@mockk.decimals } returns 6 + every { isCustom } returns false + every { iconUrl } returns null + every { contractAddress } returns "0xCONTRACT" + } + } + + private fun createCurrencyId(idValue: String, rawCurrencyId: String): CryptoCurrency.ID = mockk { + every { value } returns idValue + every { this@mockk.rawCurrencyId } returns CryptoCurrency.RawID(rawCurrencyId) + } + + private fun createNetwork(networkId: String, standardTypeName: String): Network { + val standardType: Network.StandardType = mockk { + every { name } returns standardTypeName + } + return mockk { + every { id } returns mockk { + every { rawId } returns Network.RawID(networkId) + } + every { name } returns networkId + every { isTestnet } returns false + every { this@mockk.standardType } returns standardType + } + } +} \ No newline at end of file diff --git a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/ForYouTokenRowConverterTest.kt b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/portfolioReview/ForYouPortfolioReviewTokenRowConverterTest.kt similarity index 82% rename from features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/ForYouTokenRowConverterTest.kt rename to features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/portfolioReview/ForYouPortfolioReviewTokenRowConverterTest.kt index 01d8de1102..15447c10cb 100644 --- a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/ForYouTokenRowConverterTest.kt +++ b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/portfolioReview/ForYouPortfolioReviewTokenRowConverterTest.kt @@ -1,4 +1,4 @@ -package com.tangem.features.foryou.impl.model.converter +package com.tangem.features.foryou.impl.model.converter.portfolioReview import com.google.common.truth.Truth.assertThat import com.tangem.core.ui.ds.row.token.TangemTokenRowUM @@ -12,13 +12,15 @@ 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.network.Network +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.features.foryou.impl.model.converter.toForYouPercent import io.mockk.every import io.mockk.mockk import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test import java.math.BigDecimal -internal class ForYouTokenRowConverterTest { +internal class ForYouPortfolioReviewTokenRowConverterTest { private val appCurrency: AppCurrency = AppCurrency.Default @@ -194,6 +196,51 @@ internal class ForYouTokenRowConverterTest { assertThat(bottomEnd.endIcons).hasSize(1) } + @Test + fun `GIVEN wallet id present WHEN row clicked THEN token callback receives wallet id and currency`() { + // Arrange + val currency = createCurrency(id = "coin-eth", symbol = "ETH") + val statuses = listOf( + createStatus(currency, loadedValue(amount = BigDecimal("1"), fiatAmount = BigDecimal("100"))), + ) + val walletId = UserWalletId("01") + var clicked: Pair? = null + val converter = createConverter( + totalFiatBalance = BigDecimal("1000"), + userWalletId = walletId, + onTokenClick = { id, clickedCurrency -> clicked = id to clickedCurrency }, + ) + + // Act + val result = converter.convertNetworkGroup(statuses) as TangemTokenRowUM.Content + result.onItemClick?.invoke() + + // Assert + assertThat(clicked).isEqualTo(walletId to currency) + } + + @Test + fun `GIVEN no wallet id WHEN row clicked THEN token callback is not invoked`() { + // Arrange — without a selected wallet there is nowhere to navigate, so the click is a no-op + val currency = createCurrency(id = "coin-eth", symbol = "ETH") + val statuses = listOf( + createStatus(currency, loadedValue(amount = BigDecimal("1"), fiatAmount = BigDecimal("100"))), + ) + var clicked = false + val converter = createConverter( + totalFiatBalance = BigDecimal("1000"), + userWalletId = null, + onTokenClick = { _, _ -> clicked = true }, + ) + + // Act + val result = converter.convertNetworkGroup(statuses) as TangemTokenRowUM.Content + result.onItemClick?.invoke() + + // Assert + assertThat(clicked).isFalse() + } + @Test fun `GIVEN mixed MissedDerivation and Unreachable WHEN convertNetworkGroup THEN missed-derivation wins`() { // Arrange — missed derivation is the most severe terminal state and dominates @@ -213,18 +260,23 @@ internal class ForYouTokenRowConverterTest { } } - private fun createConverter(totalFiatBalance: BigDecimal) = ForYouTokenRowConverter( + private fun createConverter( + totalFiatBalance: BigDecimal, + userWalletId: UserWalletId? = UserWalletId("01"), + onTokenClick: (UserWalletId, CryptoCurrency) -> Unit = { _, _ -> }, + ) = ForYouPortfolioReviewTokenRowConverter( appCurrency = appCurrency, + userWalletId = userWalletId, totalFiatBalance = totalFiatBalance, - onTokenClick = {}, + onTokenClick = onTokenClick, ) - /** Mirrors the production fiat rendering used by [ForYouTokenRowConverter] for a resolved row. */ + /** Mirrors the production fiat rendering used by [ForYouPortfolioReviewTokenRowConverter] for a resolved row. */ private fun BigDecimal.expectedFiatText(): TextReference = stringReference( format { fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol) }, ) - /** Mirrors the production percent-share rendering used by [ForYouTokenRowConverter] for a resolved row. */ + /** Mirrors the production percent-share rendering of [ForYouPortfolioReviewTokenRowConverter]. */ private fun BigDecimal.expectedPercentText(total: BigDecimal): TextReference = stringReference( toForYouPercent(total).format { percent() }, ) @@ -278,6 +330,7 @@ internal class ForYouTokenRowConverterTest { return mockk { every { this@mockk.id } returns currencyId every { this@mockk.symbol } returns symbol + every { this@mockk.name } returns symbol every { this@mockk.network } returns network every { this@mockk.decimals } returns 8 every { isCustom } returns false diff --git a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/transformer/SetPortfolioReviewTransformerTest.kt b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/transformer/SetPortfolioReviewTransformerTest.kt index 450b82515c..0b2cbd80b5 100644 --- a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/transformer/SetPortfolioReviewTransformerTest.kt +++ b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/transformer/SetPortfolioReviewTransformerTest.kt @@ -1,17 +1,14 @@ package com.tangem.features.foryou.impl.model.transformer import com.google.common.truth.Truth.assertThat +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.domain.models.currency.CryptoCurrencyStatus -import com.tangem.domain.models.network.Network import com.tangem.features.foryou.impl.components.state.MarketChartUM import com.tangem.features.foryou.impl.entity.EarnOpportunitiesUM -import com.tangem.features.foryou.impl.entity.ForYouTokenListItemUM import com.tangem.features.foryou.impl.entity.ForYouUM import com.tangem.features.foryou.impl.entity.PortfolioReviewUM import com.tangem.features.foryou.impl.model.ForYouNotification @@ -24,163 +21,25 @@ import java.math.BigDecimal internal class SetPortfolioReviewTransformerTest { - private val appCurrency: AppCurrency = AppCurrency.Default - @Nested - inner class TokenList { + inner class Sections { @Test - fun `GIVEN currency with resolved zero fiat balance WHEN transform THEN it is dropped from the list`() { + fun `GIVEN pre-built section UMs WHEN transform THEN both are set on the state`() { // Arrange - val zeroBalance = createCurrency(rawCurrencyId = "btc", symbol = "BTC") - val nonZeroBalance = createCurrency(rawCurrencyId = "eth", symbol = "ETH") - val currencies = listOf( - createStatus(zeroBalance, loadedValue(BigDecimal.ZERO)), - createStatus(nonZeroBalance, loadedValue(BigDecimal("100"))), + val portfolioReview = contentPortfolioReview() + val earnOpportunities = contentEarnOpportunities() + val transformer = createTransformer( + portfolioReviewUM = portfolioReview, + earnOpportunitiesUM = earnOpportunities, ) - val transformer = createTransformer(accountStatusList(currencies, loaded(BigDecimal("100")))) // Act - val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content - - // Assert — only the ETH asset survives; the zero-fiat BTC is dropped - assertThat(result.tokenList.map { it.tokenRowUM.id }).containsExactly("eth") - } - - @Test - fun `GIVEN non-content status with null fiat WHEN transform THEN it is kept not dropped`() { - // Arrange — a non-content status (Unreachable) carries a null fiatAmount, not a resolved zero; - // it must still be shown so the user sees the token they hold, with the appropriate treatment. - val unreachable = createCurrency(rawCurrencyId = "btc", symbol = "BTC") - val loaded = createCurrency(rawCurrencyId = "eth", symbol = "ETH") - val currencies = listOf( - createStatus(unreachable, unreachableValue()), - createStatus(loaded, loadedValue(BigDecimal("100"))), - ) - val transformer = createTransformer(accountStatusList(currencies, loaded(BigDecimal("100")))) - - // Act - val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content - - // Assert — both assets kept, ranked by summed fiat (eth 100 > btc 0) - assertThat(result.tokenList.map { it.tokenRowUM.id }).containsExactly("eth", "btc").inOrder() - } - - @Test - fun `GIVEN same asset across networks WHEN transform THEN aggregated into one asset ranked by summed fiat`() { - // Arrange — the same asset (shared rawCurrencyId "usdc") aggregates into one asset - val onEth = createCurrency(rawCurrencyId = "usdc", symbol = "USDC") - val onSol = createCurrency(rawCurrencyId = "usdc", symbol = "USDC") - val other = createCurrency(rawCurrencyId = "btc", symbol = "BTC") - val currencies = listOf( - createStatus(onEth, loadedValue(BigDecimal("50"))), - createStatus(onSol, loadedValue(BigDecimal("60"))), - createStatus(other, loadedValue(BigDecimal("10"))), - ) - val transformer = createTransformer(accountStatusList(currencies, loaded(BigDecimal("120")))) - - // Act - val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content - - // Assert — 2 ranked assets: usdc (110 total) ahead of btc (10) - assertThat(result.tokenList.map { it.tokenRowUM.id }).containsExactly("usdc", "btc").inOrder() - } - - @Test - fun `GIVEN more than TOP_HOLDINGS_COUNT assets WHEN transform THEN excess assets collapse into Other`() { - // Arrange — 5 distinct assets, top 4 kept individually, 5th collapsed into "Other" - val currencies = (1..5).map { index -> - createStatus( - createCurrency(rawCurrencyId = "asset-$index", symbol = "A$index"), - loadedValue(BigDecimal(100 - index)), - ) - } - val transformer = createTransformer(accountStatusList(currencies, loaded(BigDecimal("470")))) - - // Act - val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content - - // Assert — 4 top asset rows + 1 "Other" row - assertThat(result.tokenList).hasSize(5) - assertThat(result.tokenList.last().tokenRowUM.id).isEqualTo("for_you_other_assets") - } - - @Test - fun `GIVEN exactly TOP_HOLDINGS_COUNT assets WHEN transform THEN no Other row is appended`() { - // Arrange - val currencies = (1..4).map { index -> - createStatus( - createCurrency(rawCurrencyId = "asset-$index", symbol = "A$index"), - loadedValue(BigDecimal(100 - index)), - ) - } - val transformer = createTransformer(accountStatusList(currencies, loaded(BigDecimal("394")))) - - // Act - val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content + val result = transformer.transform(loadingState()) // Assert - assertThat(result.tokenList).hasSize(4) - } - - @Test - fun `GIVEN null account status list WHEN transform THEN token list is empty`() { - // Arrange - val transformer = createTransformer(accountStatusList = null) - - // Act - val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content - - // Assert - assertThat(result.tokenList).isEmpty() - } - } - - @Nested - inner class MarketChart { - - @Test - fun `GIVEN loaded total balance WHEN transform THEN market chart is Loaded with one segment per top asset`() { - // Arrange - val currencies = listOf( - createStatus(createCurrency(rawCurrencyId = "btc", symbol = "BTC"), loadedValue(BigDecimal("70"))), - createStatus(createCurrency(rawCurrencyId = "eth", symbol = "ETH"), loadedValue(BigDecimal("30"))), - ) - val transformer = createTransformer(accountStatusList(currencies, loaded(BigDecimal("100")))) - - // Act - val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content - - // Assert - val marketChart = result.marketChartUM as MarketChartUM.Loaded - assertThat(marketChart.assetCount).isEqualTo(2) - } - - @Test - fun `GIVEN non-loaded total balance WHEN transform THEN market chart is NoData`() { - // Arrange - val currencies = listOf( - createStatus(createCurrency(rawCurrencyId = "btc", symbol = "BTC"), loadedValue(BigDecimal("100"))), - ) - val transformer = createTransformer(accountStatusList(currencies, TotalFiatBalance.Loading)) - - // Act - val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content - - // Assert - assertThat(result.marketChartUM).isEqualTo(MarketChartUM.NoData) - } - - @Test - fun `GIVEN null account status list WHEN transform THEN market chart is NoData`() { - // Arrange - val transformer = createTransformer(accountStatusList = null) - - // Act - val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content - - // Assert - assertThat(result.marketChartUM).isEqualTo(MarketChartUM.NoData) + assertThat(result.portfolioReviewUM).isEqualTo(portfolioReview) + assertThat(result.earnOpportunities).isEqualTo(earnOpportunities) } } @@ -188,45 +47,37 @@ internal class SetPortfolioReviewTransformerTest { inner class PeriodPicker { @Test - fun `GIVEN prev state is Loading WHEN transform THEN period picker is freshly created with Day selected`() { + fun `GIVEN previous state is Loading WHEN transform THEN period picker is created with Day selected`() { // Arrange - val currencies = listOf( - createStatus(createCurrency(rawCurrencyId = "btc", symbol = "BTC"), loadedValue(BigDecimal("10"))), - ) - val transformer = createTransformer(accountStatusList(currencies, loaded(BigDecimal("10")))) + val transformer = createTransformer() // Act - val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content + val result = transformer.transform(loadingState()) // Assert - assertThat(result.periodPickerUM.items.map { it.title }).containsExactly( - stringReference("Day"), - stringReference("Week"), - stringReference("Month"), - ).inOrder() - assertThat(result.periodPickerUM.initialSelectedItem?.title).isEqualTo(stringReference("Day")) + assertThat(result.periodPickerUM.items).hasSize(3) + assertThat(result.periodPickerUM.initialSelectedItem).isEqualTo(result.periodPickerUM.items.first()) } @Test - fun `GIVEN prev state is Content WHEN transform THEN period picker selection is preserved`() { - // Arrange - val currencies = listOf( - createStatus(createCurrency(rawCurrencyId = "btc", symbol = "BTC"), loadedValue(BigDecimal("10"))), + fun `GIVEN previous state is Content WHEN transform THEN user's picker selection is carried over`() { + // Arrange — the user has already switched to the "Week" segment + val week = TangemSegmentUM(id = "1", title = stringReference("Week")) + val pickerWithSelection = TangemSegmentedPickerUM( + items = persistentListOf(TangemSegmentUM(id = "0", title = stringReference("Day")), week), + initialSelectedItem = week, ) - val transformer = createTransformer(accountStatusList(currencies, loaded(BigDecimal("10")))) - val prevContent = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content - val weekItem = prevContent.periodPickerUM.items[1] val prevState = loadingState().copy( - portfolioReviewUM = prevContent.copy( - periodPickerUM = prevContent.periodPickerUM.copy(initialSelectedItem = weekItem), - ), + portfolioReviewUM = contentPortfolioReview(), + periodPickerUM = pickerWithSelection, ) + val transformer = createTransformer() // Act - val result = transformer.transform(prevState).portfolioReviewUM as PortfolioReviewUM.Content + val result = transformer.transform(prevState) - // Assert - assertThat(result.periodPickerUM.initialSelectedItem).isEqualTo(weekItem) + // Assert — a balance refresh must not reset the selection back to Day + assertThat(result.periodPickerUM).isEqualTo(pickerWithSelection) } } @@ -236,11 +87,8 @@ internal class SetPortfolioReviewTransformerTest { @Test fun `GIVEN total balance from outdated source WHEN transform THEN outdated-data notification is emitted`() { // Arrange - val currencies = listOf( - createStatus(createCurrency(rawCurrencyId = "btc", symbol = "BTC"), loadedValue(BigDecimal("10"))), - ) val transformer = createTransformer( - accountStatusList(currencies, loaded(BigDecimal("10"), source = StatusSource.ONLY_CACHE)), + accountStatusList = accountStatusList(loaded(BigDecimal("10"), source = StatusSource.ONLY_CACHE)), ) // Act @@ -253,11 +101,8 @@ internal class SetPortfolioReviewTransformerTest { @Test fun `GIVEN total balance from actual source WHEN transform THEN no notification is emitted`() { // Arrange - val currencies = listOf( - createStatus(createCurrency(rawCurrencyId = "btc", symbol = "BTC"), loadedValue(BigDecimal("10"))), - ) val transformer = createTransformer( - accountStatusList(currencies, loaded(BigDecimal("10"), source = StatusSource.ACTUAL)), + accountStatusList = accountStatusList(loaded(BigDecimal("10"), source = StatusSource.ACTUAL)), ) // Act @@ -281,22 +126,28 @@ internal class SetPortfolioReviewTransformerTest { } private fun createTransformer( - accountStatusList: AccountStatusList?, - expandedAssetIds: Set = emptySet(), + accountStatusList: AccountStatusList? = null, + portfolioReviewUM: PortfolioReviewUM = contentPortfolioReview(), + earnOpportunitiesUM: EarnOpportunitiesUM = contentEarnOpportunities(), ) = SetPortfolioReviewTransformer( accountStatusList = accountStatusList, - appCurrency = appCurrency, - expandedAssetIds = expandedAssetIds, - expandClick = {}, - onPeriodClick = {}, - onTokenClick = {}, + portfolioReviewUM = portfolioReviewUM, + earnOpportunitiesUM = earnOpportunitiesUM, ) - private fun accountStatusList( - currencies: List, - totalFiatBalance: TotalFiatBalance, - ): AccountStatusList = mockk { - every { flattenCurrencies() } returns currencies + private fun contentPortfolioReview(): PortfolioReviewUM.Content = PortfolioReviewUM.Content( + tokenList = persistentListOf(), + marketChartUM = MarketChartUM.NoData, + ) + + private fun contentEarnOpportunities(): EarnOpportunitiesUM.Content = EarnOpportunitiesUM.Content( + tokenList = persistentListOf(), + subtitleRes = 0, + potentialReward = null, + potentialRewardType = null, + ) + + private fun accountStatusList(totalFiatBalance: TotalFiatBalance): AccountStatusList = mockk { every { this@mockk.totalFiatBalance } returns totalFiatBalance } @@ -305,50 +156,12 @@ internal class SetPortfolioReviewTransformerTest { private fun loadingState(): ForYouUM = ForYouUM( portfolioReviewUM = PortfolioReviewUM.Loading( - tokenList = persistentListOf(), + tokenList = persistentListOf(), marketChartUM = MarketChartUM.NoData, ), earnOpportunities = EarnOpportunitiesUM.Loading(tokenList = persistentListOf()), notifications = persistentListOf(), + periodPickerUM = TangemSegmentedPickerUM(persistentListOf()), + onPeriodClick = {}, ) - - private fun createStatus(currency: CryptoCurrency, value: CryptoCurrencyStatus.Value) = CryptoCurrencyStatus( - currency = currency, - value = value, - ) - - private fun loadedValue(fiatAmount: BigDecimal): CryptoCurrencyStatus.Loaded = mockk { - every { amount } returns BigDecimal.ONE - every { this@mockk.fiatAmount } returns fiatAmount - every { isError } returns false - every { sources } returns CryptoCurrencyStatus.Sources() - } - - /** A non-content status: carries a null fiatAmount (unknown balance), not a resolved zero. */ - private fun unreachableValue(): CryptoCurrencyStatus.Unreachable = CryptoCurrencyStatus.Unreachable( - priceChange = null, - fiatRate = null, - networkAddress = null, - ) - - private fun createCurrency(rawCurrencyId: String, symbol: String): CryptoCurrency.Coin { - val network: Network = mockk { - every { name } returns "Network" - every { isTestnet } returns false - every { id } returns mockk { every { rawId } returns Network.RawID(rawCurrencyId) } - } - val currencyId: CryptoCurrency.ID = mockk { - every { value } returns "coin-$rawCurrencyId" - every { this@mockk.rawCurrencyId } returns CryptoCurrency.RawID(rawCurrencyId) - } - return mockk { - every { this@mockk.id } returns currencyId - every { this@mockk.symbol } returns symbol - every { this@mockk.name } returns symbol - every { this@mockk.network } returns network - every { this@mockk.decimals } returns 8 - every { isCustom } returns false - every { iconUrl } returns null - } - } } \ No newline at end of file