From 2c11ec0405cb3a7351bee9248b79e663f975a54e Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 7 Jul 2026 18:12:38 +0500 Subject: [PATCH 1/2] Updated on 2026-08-14 --- .../ds2/messagebanner/TangemMessageBanner.kt | 6 +- .../features/foryou/impl/entity/ForYouUM.kt | 11 +- .../features/foryou/impl/model/ForYouModel.kt | 52 +---- .../foryou/impl/model/ForYouNotification.kt | 2 +- .../converter/ForYouMarketChartConverter.kt | 55 +++++ .../converter/ForYouPortfolioFormatters.kt | 22 +- .../converter/ForYouTokenListConverter.kt | 88 ++++---- .../converter/ForYouTokenRowConverter.kt | 194 ++++++++++++++++-- .../SetPortfolioReviewTransformer.kt | 65 +++--- .../features/foryou/impl/ui/ForYouContent.kt | 43 ++-- .../foryou/impl/ui/ForYouPortfolioReview.kt | 19 +- .../ui/components/ForYouMarketChartContent.kt | 68 ------ .../ui/components/ForYouPortfolioTokenList.kt | 6 +- .../ForYouPortfolioReviewPreviewData.kt | 28 ++- 14 files changed, 406 insertions(+), 253 deletions(-) create mode 100644 features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/ForYouMarketChartConverter.kt delete mode 100644 features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/components/ForYouMarketChartContent.kt diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds2/messagebanner/TangemMessageBanner.kt b/core/ui/src/main/java/com/tangem/core/ui/ds2/messagebanner/TangemMessageBanner.kt index 7cf3172777..570ff8a126 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds2/messagebanner/TangemMessageBanner.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds2/messagebanner/TangemMessageBanner.kt @@ -157,7 +157,7 @@ fun TangemMessageBanner( TangemMessageBanner( modifier = modifier, variant = state.variant, - showGlowRing = state.isShowGlowRing, + showGlowRing = state.shouldShowGlowRing, secondaryButton = state.secondaryButton, primaryButton = state.primaryButton, ) { @@ -341,7 +341,7 @@ object TangemMessageBanner { * @param title Banner headline. * @param variant Visual appearance — background color + glow ring. * @param contentAlign Horizontal alignment of the text block. - * @param isShowGlowRing Whether the glow ring is drawn around the banner. `false` shows only the + * @param shouldShowGlowRing Whether the glow ring is drawn around the banner. `false` shows only the * background. * @param description Secondary line under the [title]. `null` hides it. * @param secondaryButton Start action. `null` hides it. @@ -353,7 +353,7 @@ object TangemMessageBanner { val title: TextReference, val variant: Variant = Variant.Default, val contentAlign: ContentAlign = ContentAlign.Start, - val isShowGlowRing: Boolean = true, + val shouldShowGlowRing: Boolean = true, val description: TextReference? = null, val secondaryButton: Button? = null, val primaryButton: Button? = null, 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 2e284a4c7f..d1832b2bcb 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 @@ -4,28 +4,29 @@ import androidx.compose.runtime.Immutable import com.tangem.core.ui.ds.row.token.TangemTokenRowUM import com.tangem.core.ui.ds.tabs.TangemSegmentUM import com.tangem.core.ui.ds.tabs.TangemSegmentedPickerUM -import com.tangem.core.ui.extensions.TextReference -import com.tangem.features.commonfeatures.api.choosetoken.model.WalletListUM +import com.tangem.features.foryou.impl.components.state.MarketChartUM +import com.tangem.features.foryou.impl.model.ForYouNotification import kotlinx.collections.immutable.ImmutableList internal data class ForYouUM( - val walletListUM: WalletListUM, val portfolioReviewUM: PortfolioReviewUM, + val notifications: ImmutableList, ) @Immutable internal sealed interface PortfolioReviewUM { val tokenList: ImmutableList + val marketChartUM: MarketChartUM data class Loading( override val tokenList: ImmutableList, + override val marketChartUM: MarketChartUM.NoData, ) : PortfolioReviewUM data class Content( override val tokenList: ImmutableList, + override val marketChartUM: MarketChartUM, val periodPickerUM: TangemSegmentedPickerUM, - val assetCount: TextReference, - val topHoldingPercent: TextReference, val onPeriodClick: (TangemSegmentUM) -> Unit, ) : PortfolioReviewUM } 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 2ec2831339..3a1b5f1bfd 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,27 +2,20 @@ package com.tangem.features.foryou.impl.model import androidx.compose.runtime.Stable import arrow.core.getOrElse -import com.tangem.common.ui.userwallet.converter.WalletIconUMConverter import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.ui.ds.row.token.TangemTokenRowUM import com.tangem.core.ui.ds.tabs.TangemSegmentUM -import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.common.wallets.UserWalletsListRepository -import com.tangem.domain.models.TotalFiatBalance -import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.wallets.usecase.GetWalletIconUseCase -import com.tangem.features.commonfeatures.api.choosetoken.model.WalletListUM -import com.tangem.features.commonfeatures.api.choosetoken.model.WalletTabUM +import com.tangem.features.foryou.impl.components.state.MarketChartUM 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.transformer.SetPortfolioReviewTransformer import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import com.tangem.utils.extensions.orZero import com.tangem.utils.transformer.update import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList @@ -36,19 +29,17 @@ internal class ForYouModel @Inject constructor( multiAccountStatusListSupplier: MultiAccountStatusListSupplier, override val dispatchers: CoroutineDispatcherProvider, private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, - private val walletIconUMConverter: WalletIconUMConverter, - private val getWalletIconUseCase: GetWalletIconUseCase, ) : Model() { - private val locallySelectedWalletId = MutableStateFlow(value = null) private val expandedAssetIds = MutableStateFlow>(value = emptySet()) private val selectedAppCurrencyFlow: StateFlow = createSelectedAppCurrencyFlow() val uiState: StateFlow field = MutableStateFlow( ForYouUM( - walletListUM = WalletListUM(persistentListOf()), + notifications = persistentListOf(), portfolioReviewUM = PortfolioReviewUM.Loading( + marketChartUM = MarketChartUM.NoData, tokenList = buildList { repeat(4) { index -> add( @@ -69,35 +60,14 @@ internal class ForYouModel @Inject constructor( init { combine( - flow = userWalletsListRepository.userWallets, - flow2 = userWalletsListRepository.selectedUserWallet, - flow3 = multiAccountStatusListSupplier.invokeAsMap(), - flow4 = locallySelectedWalletId, - flow5 = expandedAssetIds, - ) { wallets, globalSelectedWallet, accountStatusList, locallySelected, expanded -> - val selectedId = locallySelected ?: globalSelectedWallet?.walletId - val tabs = wallets.orEmpty().map { wallet -> - WalletTabUM( - text = stringReference(wallet.name), - count = null, - isSelected = wallet.walletId == selectedId, - onClick = { onTabClick(wallet.walletId) }, - deviceIcon = walletIconUMConverter.convert(getWalletIconUseCase(wallet)), - ) - } - - val selectedAccountStatusList = accountStatusList[selectedId] - val currencies = selectedAccountStatusList?.flattenCurrencies().orEmpty() - val loadedBalance = selectedAccountStatusList?.totalFiatBalance as? TotalFiatBalance.Loaded - val totalFiatBalance = loadedBalance?.amount.orZero() - + flow = userWalletsListRepository.selectedUserWallet, + flow2 = multiAccountStatusListSupplier.invokeAsMap(), + flow3 = expandedAssetIds, + ) { globalSelectedWallet, accountStatusList, expanded -> + // TODO For You add choose portfolio flow uiState.update( SetPortfolioReviewTransformer( - walletListUM = WalletListUM( - items = if (tabs.size != 1) tabs.toPersistentList() else persistentListOf(), - ), - currencies = currencies, - totalFiatBalance = totalFiatBalance, + accountStatusList = accountStatusList[globalSelectedWallet?.walletId], appCurrency = selectedAppCurrencyFlow.value, expandedAssetIds = expanded, expandClick = ::onExpandClick, @@ -119,10 +89,6 @@ internal class ForYouModel @Inject constructor( ) } - private fun onTabClick(walletId: UserWalletId) { - locallySelectedWalletId.value = walletId - } - private fun onExpandClick(assetId: String) { expandedAssetIds.update { ids -> if (assetId in ids) ids - assetId else ids + assetId diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/ForYouNotification.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/ForYouNotification.kt index 612fe713bb..e3903bf09c 100644 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/ForYouNotification.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/ForYouNotification.kt @@ -20,7 +20,7 @@ internal sealed class ForYouNotification(val state: TangemMessageBanner.State) { tintReference = { TangemTheme.colors3.icon.primary }, ), variant = TangemMessageBanner.Variant.Warning, - isShowGlowRing = false, + shouldShowGlowRing = false, ), ) } \ 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/ForYouMarketChartConverter.kt new file mode 100644 index 0000000000..e767adc242 --- /dev/null +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/ForYouMarketChartConverter.kt @@ -0,0 +1,55 @@ +package com.tangem.features.foryou.impl.model.converter + +import com.tangem.core.ui.extensions.stringReference +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.TotalFiatBalance +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.features.foryou.impl.components.state.* +import com.tangem.utils.converter.Converter +import com.tangem.utils.extensions.orZero +import kotlinx.collections.immutable.toPersistentList +import java.math.BigDecimal + +internal class ForYouMarketChartConverter( + private val appCurrency: AppCurrency, + private val topAssets: List, BigDecimal>>, +) : Converter { + override fun convert(value: TotalFiatBalance?): MarketChartUM { + val topBalance = topAssets.sumOf { (_, assetBalance) -> assetBalance } + return when (value) { + is TotalFiatBalance.Loaded -> MarketChartUM.Loaded( + donutChart = DonutChartUM.Loaded( + totalAmount = value.amount.format { + fiat( + fiatCurrencySymbol = appCurrency.symbol, + fiatCurrencyCode = appCurrency.code, + ) + }, + donutSegmentList = topAssets.mapIndexed { index, (currencies, segmentBalance) -> + val segmentWeight = segmentBalance.toForYouPercent(value.amount).orZero() + DonutSegmentUM( + color = DonutSegmentColor.entries.getOrNull(index) ?: DonutSegmentColor.Brand, + weight = segmentWeight, + title = stringReference(currencies.firstOrNull()?.currency?.name.orEmpty()), + fiatValue = stringReference(segmentBalance.format { + fiat( + fiatCurrencyCode = appCurrency.code, + fiatCurrencySymbol = appCurrency.symbol, + ) + }), + ) + }.toPersistentList(), + ), + aiInsight = AiInsightUM.Hide, + topHoldingPercent = stringReference(topBalance.toForYouPercent(value.amount).format { percent() }), + ) + TotalFiatBalance.Loading, + TotalFiatBalance.Failed, + null, + -> MarketChartUM.NoData + } + } +} \ No newline at end of file diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/ForYouPortfolioFormatters.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/ForYouPortfolioFormatters.kt index 511ececb33..5596541fff 100644 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/ForYouPortfolioFormatters.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/ForYouPortfolioFormatters.kt @@ -4,12 +4,7 @@ import com.tangem.core.ui.ds.badge.TangemBadgeColor import com.tangem.core.ui.ds.badge.TangemBadgeSize import com.tangem.core.ui.ds.badge.TangemBadgeType import com.tangem.core.ui.ds.badge.TangemBadgeUM -import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.stringReference -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.CryptoCurrencyStatus import com.tangem.utils.extensions.isZero import java.math.BigDecimal @@ -19,7 +14,7 @@ import java.math.RoundingMode * Formatting helpers shared by the For You portfolio-review converters. * * Kept null-safe so that non-[CryptoCurrencyStatus.Loaded] states (which carry no fiat amount) degrade - * to a dash / empty instead of throwing. + * to `null` instead of throwing. */ /** @@ -29,18 +24,13 @@ import java.math.RoundingMode */ internal fun CryptoCurrencyStatus.forYouGroupKey(): String = currency.id.rawCurrencyId?.value ?: currency.id.value -/** Formats a (possibly null) fiat amount in [appCurrency]; a `null` amount renders as a dash. */ -internal fun BigDecimal?.toForYouFiatText(appCurrency: AppCurrency): TextReference = stringReference( - format { fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol) }, -) - /** - * Formats this fiat amount as a share of [totalFiatBalance]. Returns [TextReference.EMPTY] when the - * share cannot be computed (no amount, or a zero total / amount). + * Computes this fiat amount as a share of [totalFiatBalance]. Returns `null` when the share cannot be + * computed (no amount, or a zero total / amount). */ -internal fun BigDecimal?.toForYouPercentText(totalFiatBalance: BigDecimal): TextReference { - if (this == null || totalFiatBalance.isZero() || isZero()) return TextReference.EMPTY - return stringReference(divide(totalFiatBalance, RoundingMode.HALF_UP).format { percent() }) +internal fun BigDecimal?.toForYouPercent(totalFiatBalance: BigDecimal): BigDecimal? { + if (this == null || totalFiatBalance.isZero() || isZero()) return null + return divide(totalFiatBalance, RoundingMode.HALF_UP) } // TODO For You: replace this placeholder with the real price-change badge once the design is wired. diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/ForYouTokenListConverter.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/ForYouTokenListConverter.kt index 21ba003146..562dfeb547 100644 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/ForYouTokenListConverter.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/ForYouTokenListConverter.kt @@ -5,8 +5,12 @@ import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.ds.image.TangemIconUM import com.tangem.core.ui.ds.row.token.TangemTokenRowUM import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.pluralReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.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.CryptoCurrencyStatus @@ -29,14 +33,12 @@ import java.math.BigDecimal * * Modelled on `TokenListStateConverter` (a list converter delegating to a per-item converter). */ -@Suppress("LongParameterList") internal class ForYouTokenListConverter( private val appCurrency: AppCurrency, private val totalFiatBalance: BigDecimal, private val expandedAssetIds: Set, private val expandClick: (assetId: String) -> Unit, - private val otherAssetCount: Int, - private val otherFiatBalance: BigDecimal, + private val otherAssets: List, BigDecimal>>, ) : Converter, ImmutableList> { private val iconConverter = CryptoCurrencyToIconStateConverter() @@ -48,7 +50,7 @@ internal class ForYouTokenListConverter( .map { (assetId, currencies) -> createListItem(assetId, currencies) } // Assets beyond the top ones are collapsed into a single non-expandable "Other" row at the bottom. - return if (otherAssetCount > 0) { + return if (otherAssets.count() > 0) { assetItems + createOtherItem() } else { assetItems @@ -87,16 +89,16 @@ internal class ForYouTokenListConverter( val asset = currencies.first() val assetFiatBalance = currencies.sumOf { it.value.fiatAmount.orZero() } - val subtitle = if (networkCount > 1) { - stringReference("$networkCount networks") - } else { - val onlyCryptoCurrency = currencies.firstOrNull()?.currency - val isMain = onlyCryptoCurrency is CryptoCurrency.Coin - when { - isMain -> resourceReference(R.string.common_main_network) - onlyCryptoCurrency != null -> stringReference(onlyCryptoCurrency.network.standardType.name) - else -> TextReference.EMPTY - } + val endContent = rowConverter.toEndContent(statuses = currencies, fiatAmount = assetFiatBalance) + + val onlyCryptoCurrency = currencies.firstOrNull()?.currency + val isMain = onlyCryptoCurrency is CryptoCurrency.Coin + + val subtitle = when { + networkCount > 1 -> pluralReference(R.plurals.common_networks_count, networkCount) + isMain -> resourceReference(R.string.common_main_network) + onlyCryptoCurrency != null -> stringReference(onlyCryptoCurrency.network.standardType.name) + else -> TextReference.EMPTY } return TangemTokenRowUM.Content( @@ -109,38 +111,44 @@ internal class ForYouTokenListConverter( subtitleUM = TangemTokenRowUM.SubtitleUM.Content( text = subtitle, ), - topEndContentUM = TangemTokenRowUM.EndContentUM.Content( - text = assetFiatBalance.toForYouFiatText(appCurrency), - ), - bottomEndContentUM = TangemTokenRowUM.EndContentUM.Content( - text = assetFiatBalance.toForYouPercentText(totalFiatBalance), - ), + topEndContentUM = endContent.top, + bottomEndContentUM = endContent.bottom, onItemClick = { expandClick(assetId) }, onItemLongClick = null, ) } - private fun createOtherItem(): ForYouTokenListItemUM = ForYouTokenListItemUM( - tokenRowUM = TangemTokenRowUM.Content( - id = OTHER_ROW_ID, - headIconUM = TangemIconUM.Currency(CurrencyIconState.Empty()), - titleUM = TangemTokenRowUM.TitleUM.Content(text = stringReference("Other")), - subtitleUM = TangemTokenRowUM.SubtitleUM.Content( - text = stringReference(if (otherAssetCount > 1) "$otherAssetCount assets" else "1 asset"), + private fun createOtherItem(): ForYouTokenListItemUM { + val otherAssetsBalance = otherAssets.sumOf { (_, assetBalance) -> assetBalance } + return ForYouTokenListItemUM( + tokenRowUM = TangemTokenRowUM.Content( + id = OTHER_ROW_ID, + headIconUM = TangemIconUM.Currency(CurrencyIconState.Empty()), + titleUM = TangemTokenRowUM.TitleUM.Content(text = resourceReference(R.string.common_other)), + subtitleUM = TangemTokenRowUM.SubtitleUM.Content( + text = pluralReference(R.plurals.common_assets, otherAssets.count()), + ), + topEndContentUM = TangemTokenRowUM.EndContentUM.Content( + text = stringReference( + otherAssetsBalance.format { + fiat( + fiatCurrencyCode = appCurrency.code, + fiatCurrencySymbol = appCurrency.symbol, + ) + }, + ), + ), + bottomEndContentUM = TangemTokenRowUM.EndContentUM.Content( + text = stringReference(otherAssetsBalance.toForYouPercent(totalFiatBalance).format { percent() }), + ), + onItemClick = null, + onItemLongClick = null, ), - topEndContentUM = TangemTokenRowUM.EndContentUM.Content( - text = otherFiatBalance.toForYouFiatText(appCurrency), - ), - bottomEndContentUM = TangemTokenRowUM.EndContentUM.Content( - text = otherFiatBalance.toForYouPercentText(totalFiatBalance), - ), - onItemClick = null, - onItemLongClick = null, - ), - tokenList = persistentListOf(), - isExpanded = false, - isExpandable = false, - ) + tokenList = persistentListOf(), + isExpanded = false, + isExpandable = false, + ) + } private companion object { const val OTHER_ROW_ID = "for_you_other_assets" 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/ForYouTokenRowConverter.kt index 5a917c91a5..c9ae8985eb 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/ForYouTokenRowConverter.kt @@ -1,15 +1,25 @@ package com.tangem.features.foryou.impl.model.converter +import androidx.compose.ui.text.SpanStyle import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.core.ui.R import com.tangem.core.ui.ds.image.TangemIconUM import com.tangem.core.ui.ds.row.token.TangemTokenRowUM import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.styledResourceReference import com.tangem.core.ui.format.bigdecimal.crypto +import com.tangem.core.ui.format.bigdecimal.fiat import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.format.bigdecimal.percent +import com.tangem.core.ui.res.TangemTheme import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.StatusSource +import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.utils.StringsSigns import com.tangem.utils.extensions.orZero +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList import java.math.BigDecimal /** @@ -18,6 +28,16 @@ import java.math.BigDecimal * The input is all [CryptoCurrencyStatus]es of one asset on the *same* network (the asset may be held in * several accounts on that network). They are aggregated into one row — the crypto amount and fiat balance * are the per-network totals — so a network never appears twice within an asset's expanded breakdown. + * + * 1. all [CryptoCurrencyStatus.Loading] → a Loading row; + * 2. any [CryptoCurrencyStatus.MissedDerivation] → "no address" treatment (missing address dominates — + * the balance for that portion can't be trusted); + * 3. any [CryptoCurrencyStatus.Unreachable] / [CryptoCurrencyStatus.NoAmount] → "unreachable" treatment; + * 4. otherwise a normal content row summing the loaded/custom/no-quote/no-account amounts. + * + * [CryptoCurrencyStatus.Loading] entries inside an otherwise-resolved group are ignored for + * classification (they contribute nothing yet). The cache/flicker indicators derive from the most + * conservative [CryptoCurrencyStatus.Sources.total] across the contributing statuses. */ internal class ForYouTokenRowConverter( private val appCurrency: AppCurrency, @@ -36,26 +56,170 @@ internal class ForYouTokenRowConverter( val currency = representative.currency val cryptoAmount = statuses.sumOf { it.value.amount.orZero() } val fiatAmount = statuses.sumOf { it.value.fiatAmount.orZero() } + val state = statuses.classify() + return TangemTokenRowUM.Content( id = currency.id.value, headIconUM = TangemIconUM.Currency(iconConverter.convert(representative)), - titleUM = TangemTokenRowUM.TitleUM.Content( - text = stringReference(currency.symbol), - badge = forYouPlaceholderBadge(), - ), - subtitleUM = TangemTokenRowUM.SubtitleUM.Content( - text = stringReference( - "${currency.network.name} ${StringsSigns.DOT} ${cryptoAmount.format { crypto( - cryptoCurrency = currency, - ) }}", - ), - ), - topEndContentUM = TangemTokenRowUM.EndContentUM.Content(text = fiatAmount.toForYouFiatText(appCurrency)), - bottomEndContentUM = TangemTokenRowUM.EndContentUM.Content( - text = fiatAmount.toForYouPercentText(totalFiatBalance), - ), + titleUM = toRowTitle(currency), + subtitleUM = toRowSubtitle(state, currency, cryptoAmount), + topEndContentUM = toRowTopEnd(state, fiatAmount), + bottomEndContentUM = toRowBottomEnd(state, fiatAmount), onItemClick = null, onItemLongClick = null, ) } + + /** + * Maps the aggregate status of [statuses] onto a row's top/bottom end content, rendering the given + * pre-summed [fiatAmount]. Reflects the same cache-flicker / could-not-refresh / no-address / + * unreachable treatment as [convertNetworkGroup], so the asset-level row surfaces the combined status + * of its holdings — analogous to how `AccountCryptoPortfolioItemStateConverter` reflects a + * `TotalFiatBalance`'s status on the account row. + * + * Callers must handle the all-[CryptoCurrencyStatus.Loading] case (a Loading row) before calling this. + */ + fun toEndContent(statuses: List, fiatAmount: BigDecimal): EndContent { + val state = statuses.classify() + return EndContent( + top = toRowTopEnd(state, fiatAmount), + bottom = toRowBottomEnd(state, fiatAmount), + ) + } + + /** Title: For You always shows the asset symbol with the placeholder price-change badge. */ + private fun toRowTitle(currency: CryptoCurrency): TangemTokenRowUM.TitleUM = TangemTokenRowUM.TitleUM.Content( + text = stringReference(currency.symbol), + badge = forYouPlaceholderBadge(), + ) + + /** + * Subtitle: `network • amount` for resolved states, error messaging otherwise. Kept single-line to + * match For You's style (no separate price-change line as in the wallet). + */ + private fun toRowSubtitle( + state: RowState, + currency: CryptoCurrency, + cryptoAmount: BigDecimal, + ): TangemTokenRowUM.SubtitleUM = when (state) { + is RowState.Normal -> TangemTokenRowUM.SubtitleUM.Content( + text = stringReference( + "${currency.network.name} ${StringsSigns.DOT} ${ + cryptoAmount.format { + crypto( + cryptoCurrency = currency, + ) + } + }", + ), + isFlickering = state.isFlickering, + ) + RowState.NoAddress -> TangemTokenRowUM.SubtitleUM.Content( + text = stringReference("${currency.network.name} ${StringsSigns.DOT} ${StringsSigns.DASH_SIGN}"), + ) + RowState.Unreachable -> TangemTokenRowUM.SubtitleUM.Content( + text = stringReference(currency.network.name), + ) + } + + /** Top-end: fiat total for resolved states, dash / unreachable treatment otherwise. */ + private fun toRowTopEnd(state: RowState, fiatAmount: BigDecimal): TangemTokenRowUM.EndContentUM = when (state) { + is RowState.Normal -> TangemTokenRowUM.EndContentUM.Content( + text = stringReference( + fiatAmount.format { + fiat( + fiatCurrencyCode = appCurrency.code, + fiatCurrencySymbol = appCurrency.symbol, + ) + }, + ), + isFlickering = state.isFlickering, + startIcons = buildList { + if (state.isOnlyCache) { + add( + TangemIconUM.Icon( + iconRes = R.drawable.ic_error_sync_default_24, + tintReference = { TangemTheme.colors3.icon.tertiary }, + ), + ) + } + }.toImmutableList(), + ) + RowState.NoAddress, + RowState.Unreachable, + -> TangemTokenRowUM.EndContentUM.Content(text = stringReference(StringsSigns.DASH_SIGN)) + } + + /** Bottom-end: percentage share for resolved states, no-address / unreachable treatment otherwise. */ + private fun toRowBottomEnd(state: RowState, fiatAmount: BigDecimal): TangemTokenRowUM.EndContentUM = when (state) { + is RowState.Normal -> TangemTokenRowUM.EndContentUM.Content( + text = stringReference(fiatAmount.toForYouPercent(totalFiatBalance).format { percent() }), + isFlickering = state.isFlickering, + ) + RowState.NoAddress -> attentionEndContent(R.string.common_no_address) + RowState.Unreachable -> attentionEndContent(R.string.common_unreachable) + } + + private fun attentionEndContent(textRes: Int): TangemTokenRowUM.EndContentUM = + TangemTokenRowUM.EndContentUM.Content( + text = styledResourceReference( + id = textRes, + spanStyleReference = { SpanStyle(color = TangemTheme.colors3.text.status.warning) }, + ), + endIcons = persistentListOf( + TangemIconUM.Icon( + iconRes = R.drawable.ic_attention_default_24, + tintReference = { TangemTheme.colors3.icon.status.warning }, + ), + ), + ) + + /** + * Collapses a mixed group into a single [RowState]. Loading-only groups are handled earlier, so a + * group reaching here has at least one non-loading status. See the class KDoc for the priority rule. + */ + private fun List.classify(): RowState { + val resolved = filterNot { it.value is CryptoCurrencyStatus.Loading }.map { it.value } + return when { + resolved.any { it is CryptoCurrencyStatus.MissedDerivation } -> RowState.NoAddress + resolved.any { + it is CryptoCurrencyStatus.Unreachable || it is CryptoCurrencyStatus.NoAmount + } -> RowState.Unreachable + else -> { + val worstSource = resolved.map { it.sources.total }.worst() + RowState.Normal( + isFlickering = worstSource == StatusSource.CACHE, + isOnlyCache = worstSource == StatusSource.ONLY_CACHE, + ) + } + } + } + + /** + * The most conservative status across the group: any [StatusSource.ONLY_CACHE] (could-not-refresh) + * dominates a [StatusSource.CACHE] (still refreshing), which in turn dominates [StatusSource.ACTUAL]. + */ + private fun List.worst(): StatusSource = when { + any { it == StatusSource.ONLY_CACHE } -> StatusSource.ONLY_CACHE + any { it == StatusSource.CACHE } -> StatusSource.CACHE + else -> StatusSource.ACTUAL + } + + /** The top and bottom end content of a token row, produced together from one classified group. */ + data class EndContent( + val top: TangemTokenRowUM.EndContentUM, + val bottom: TangemTokenRowUM.EndContentUM, + ) + + /** Rendering-relevant collapse of the group's per-currency-status states. */ + private sealed interface RowState { + /** Loaded / Custom / NoQuote / NoAccount — normal amounts, with cache/flicker indicators. */ + data class Normal(val isFlickering: Boolean, val isOnlyCache: Boolean) : RowState + + /** At least one MissedDerivation — no blockchain address obtained. */ + data object NoAddress : RowState + + /** At least one Unreachable / NoAmount — network could not be reached. */ + data object Unreachable : RowState + } } \ No newline at end of file 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 e9dc792f7c..99874a142f 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 @@ -3,38 +3,35 @@ package com.tangem.features.foryou.impl.model.transformer import com.tangem.core.ui.ds.tabs.TangemSegmentUM import com.tangem.core.ui.ds.tabs.TangemSegmentedPickerUM import com.tangem.core.ui.extensions.stringReference -import com.tangem.core.ui.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.currency.CryptoCurrencyStatus -import com.tangem.features.commonfeatures.api.choosetoken.model.WalletListUM +import com.tangem.domain.models.StatusSource +import com.tangem.domain.models.TotalFiatBalance 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.StringsSigns import com.tangem.utils.extensions.isZero import com.tangem.utils.extensions.orZero import com.tangem.utils.transformer.Transformer import kotlinx.collections.immutable.persistentListOf -import java.math.BigDecimal -import java.math.RoundingMode /** - * Builds the [ForYouUM] state for the For You screen: the wallet tabs plus the portfolio - * review (asset count, top-holding share, period picker and the grouped token list). + * 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). * - * The token list is delegated to [ForYouTokenListConverter]; the period picker selection is carried - * over from the previous state so it is not reset on every balance refresh. + * 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. * * Modelled on `SetTokenListTransformer` (a transformer that rebuilds the state while delegating the * token-list construction to a dedicated converter). */ @Suppress("LongParameterList") internal class SetPortfolioReviewTransformer( - private val walletListUM: WalletListUM, - private val currencies: List, - private val totalFiatBalance: BigDecimal, + private val accountStatusList: AccountStatusList?, private val appCurrency: AppCurrency, private val expandedAssetIds: Set, private val expandClick: (assetId: String) -> Unit, @@ -42,10 +39,18 @@ internal class SetPortfolioReviewTransformer( ) : Transformer { override fun transform(prevState: ForYouUM): ForYouUM { - // Drop empty networks, then aggregate the rest into assets (the same token across networks shares - // its forYouGroupKey) and rank assets by their *summed* fiat balance. + 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.orZero().isZero() } + .filterNot { it.value.fiatAmount?.isZero() == true } .groupBy { it.forYouGroupKey() } .map { (_, networks) -> networks to networks.sumOf { it.value.fiatAmount.orZero() } } .sortedByDescending { (_, assetBalance) -> assetBalance } @@ -55,40 +60,38 @@ internal class SetPortfolioReviewTransformer( val topAssets = rankedAssets.take(TOP_HOLDINGS_COUNT) val otherAssets = rankedAssets.drop(TOP_HOLDINGS_COUNT) val topCurrencies = topAssets.flatMap { (networks, _) -> networks } - val topBalance = topAssets.sumOf { (_, assetBalance) -> assetBalance } val tokenList = ForYouTokenListConverter( appCurrency = appCurrency, totalFiatBalance = totalFiatBalance, expandedAssetIds = expandedAssetIds, expandClick = expandClick, - otherAssetCount = otherAssets.size, - otherFiatBalance = otherAssets.sumOf { (_, assetBalance) -> assetBalance }, + otherAssets = otherAssets, ).convert(topCurrencies) + val marketChartUM = ForYouMarketChartConverter( + appCurrency = appCurrency, + topAssets = topAssets, + ).convert(accountStatusList?.totalFiatBalance) + return prevState.copy( - walletListUM = walletListUM, + notifications = if (loadedBalance?.source == StatusSource.ONLY_CACHE) { + persistentListOf(ForYouNotification.UsedOutdatedData) + } else { + persistentListOf() + }, portfolioReviewUM = PortfolioReviewUM.Content( - assetCount = stringReference("${rankedAssets.size} assets"), // TODO For You lokalize - topHoldingPercent = stringReference("Top holding ${topHoldingPercent(topBalance)}"), periodPickerUM = when (prevState.portfolioReviewUM) { is PortfolioReviewUM.Content -> prevState.portfolioReviewUM.periodPickerUM is PortfolioReviewUM.Loading -> createPeriodPicker() }, tokenList = tokenList, + marketChartUM = marketChartUM, onPeriodClick = onPeriodClick, ), ) } - private fun topHoldingPercent(topBalance: BigDecimal): String { - return if (!totalFiatBalance.isZero() && !topBalance.isZero()) { - topBalance.divide(totalFiatBalance, RoundingMode.HALF_UP).format { percent() } - } else { - StringsSigns.DASH_SIGN - } - } - private fun createPeriodPicker(): TangemSegmentedPickerUM { // TODO For you replace with data from backend val day = TangemSegmentUM(id = "0", title = stringReference("Day")) 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 baec193e24..f4d1a5f9f6 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 @@ -16,17 +16,16 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.Dp 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.image.DeviceIconUM -import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.ds2.messagebanner.TangemMessageBanner +import com.tangem.core.ui.extensions.conditional import com.tangem.core.ui.res.LocalMainBottomSheetColor import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreviewRedesign -import com.tangem.features.commonfeatures.api.choosetoken.model.WalletListUM -import com.tangem.features.commonfeatures.api.choosetoken.model.WalletTabUM +import com.tangem.features.foryou.impl.model.ForYouNotification import com.tangem.features.foryou.impl.entity.ForYouUM -import com.tangem.features.foryou.impl.ui.components.WalletTabsBlock import com.tangem.features.foryou.impl.ui.preview.ForYouPortfolioReviewPreviewData import com.tangem.features.promobanners.api.PromoBannersBlockComponent import kotlinx.collections.immutable.persistentListOf @@ -54,15 +53,27 @@ internal fun ForYouContent( .padding(top = contentPadding.calculateTopPadding()) .drawBehind { drawRect(background.value) }, ) { - WalletTabsBlock(walletList = forYouUM.walletListUM) - - SpacerH(12.dp) - promoBannersBlockComponent.ContentWithPadding( - modifier = Modifier, + modifier = Modifier.padding(top = 12.dp), horizontalItemPadding = 16.dp, ) + forYouUM.notifications.fastForEachIndexed { index, notification -> + key(notification.state) { + TangemMessageBanner( + state = notification.state, + modifier = Modifier + .padding(horizontal = 16.dp) + .conditional(index == 0) { + padding(top = 12.dp) + } + .conditional(index == forYouUM.notifications.lastIndex) { + padding(bottom = 48.dp) + }, + ) + } + } + ForYouPortfolioReview( portfolioReviewUM = forYouUM.portfolioReviewUM, modifier = Modifier.padding(horizontal = 16.dp), @@ -98,17 +109,7 @@ private class ForYouContentPreviewProvider : PreviewParameterProvider override val values: Sequence get() = sequenceOf( ForYouUM( - walletListUM = WalletListUM( - items = persistentListOf( - WalletTabUM( - text = stringReference("Wallet 1"), - count = stringReference("1"), - isSelected = true, - onClick = {}, - deviceIcon = DeviceIconUM.Mobile, - ), - ), - ), + notifications = persistentListOf(ForYouNotification.UsedOutdatedData), portfolioReviewUM = ForYouPortfolioReviewPreviewData.reviewContent, ), ) 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 39470899b6..5cf487bab4 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 @@ -11,20 +11,23 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp -import com.tangem.core.ui.components.RectangleShimmer 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.TangemSegmentedPicker import com.tangem.core.ui.ds2.badge.TangemBadge +import com.tangem.core.ui.ds2.shimmers.TangemShimmer import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreviewRedesign import com.tangem.core.ui.res.generated.icons.Icons import com.tangem.core.ui.res.generated.icons.ic_chevron_down_16 +import com.tangem.features.foryou.impl.R +import com.tangem.features.foryou.impl.components.MarketChart +import com.tangem.features.foryou.impl.components.state.MarketChartUM import com.tangem.features.foryou.impl.entity.ForYouTokenListItemUM import com.tangem.features.foryou.impl.entity.PortfolioReviewUM -import com.tangem.features.foryou.impl.ui.components.ForYouMarketChartContent import com.tangem.features.foryou.impl.ui.components.ForYouPortfolioTokenList import com.tangem.features.foryou.impl.ui.preview.ForYouPortfolioReviewPreviewData import kotlinx.collections.immutable.persistentListOf @@ -39,7 +42,7 @@ internal fun ForYouPortfolioReview(portfolioReviewUM: PortfolioReviewUM, modifie verticalAlignment = Alignment.CenterVertically, ) { Text( - text = "Portfolio Review", // TODO For You + text = stringResourceSafe(R.string.for_you_portfolio_review_title), style = TangemTheme.typography3.heading.small, color = TangemTheme.colors3.text.primary, ) @@ -51,8 +54,13 @@ internal fun ForYouPortfolioReview(portfolioReviewUM: PortfolioReviewUM, modifie ) } SpacerH(16.dp) - ForYouMarketChartContent(portfolioReviewUM) + + MarketChart( + marketChart = portfolioReviewUM.marketChartUM, + modifier = Modifier.fillMaxWidth(), + ) SpacerH(8.dp) + when (portfolioReviewUM) { is PortfolioReviewUM.Content -> { TangemSegmentedPicker( @@ -60,7 +68,7 @@ internal fun ForYouPortfolioReview(portfolioReviewUM: PortfolioReviewUM, modifie onClick = portfolioReviewUM.onPeriodClick, ) } - is PortfolioReviewUM.Loading -> RectangleShimmer( + is PortfolioReviewUM.Loading -> TangemShimmer( modifier = Modifier .fillMaxWidth() .height(40.dp), @@ -92,6 +100,7 @@ private class ForYouPortfolioReviewPreviewProvider : PreviewParameterProvider add( diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/components/ForYouMarketChartContent.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/components/ForYouMarketChartContent.kt deleted file mode 100644 index f7fe51595f..0000000000 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/components/ForYouMarketChartContent.kt +++ /dev/null @@ -1,68 +0,0 @@ -package com.tangem.features.foryou.impl.ui.components - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color.Companion.Cyan -import androidx.compose.ui.unit.dp -import com.tangem.core.ui.components.SpacerH -import com.tangem.core.ui.components.TextShimmer -import com.tangem.core.ui.extensions.resolveReference -import com.tangem.core.ui.res.TangemTheme -import com.tangem.features.foryou.impl.entity.PortfolioReviewUM - -@Composable -internal fun ForYouMarketChartContent(portfolioReviewUM: PortfolioReviewUM) { - Column( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(24.dp)) - .background(TangemTheme.colors3.bg.secondary) - .padding(16.dp), - ) { - Box( - modifier = Modifier - .align(Alignment.CenterHorizontally) - .padding( - top = 16.dp, - end = 32.dp, - start = 32.dp, - bottom = 32.dp, - ) - .background(Cyan, CircleShape) - .size(200.dp), - ) - SpacerH(16.dp) - - when (portfolioReviewUM) { - is PortfolioReviewUM.Content -> { - Text( - text = portfolioReviewUM.assetCount.resolveReference(), - style = TangemTheme.typography3.heading.small, - color = TangemTheme.colors3.text.secondary, - ) - Text( - text = portfolioReviewUM.topHoldingPercent.resolveReference(), - style = TangemTheme.typography3.heading.small, - color = TangemTheme.colors3.text.primary, - ) - } - is PortfolioReviewUM.Loading -> { - TextShimmer( - style = TangemTheme.typography3.heading.small, - modifier = Modifier.width(50.dp), - ) - TextShimmer( - style = TangemTheme.typography3.heading.small, - modifier = Modifier.width(75.dp), - ) - } - } - } -} \ No newline at end of file diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/components/ForYouPortfolioTokenList.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/components/ForYouPortfolioTokenList.kt index 428abb6f90..b3ef98fcd2 100644 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/components/ForYouPortfolioTokenList.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/components/ForYouPortfolioTokenList.kt @@ -46,7 +46,7 @@ import com.tangem.core.ui.extensions.* import com.tangem.core.ui.res.TangemColorPalette import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.generated.icons.Icons -import com.tangem.core.ui.res.generated.icons.ic_error_20 +import com.tangem.core.ui.res.generated.icons.ic_chevron_collapse_20 import com.tangem.core.ui.utils.ProvideSharedTransitionScope import com.tangem.core.ui.utils.lazyListItemPosition import com.tangem.core.ui.utils.sharedBoundsSafely @@ -129,7 +129,7 @@ private fun PortfolioAssetItem(listItem: ForYouTokenListItemUM, index: Int, oute targetState = listItem.isExpanded, transitionSpec = { portfolioAssetExpandFadeAnimation() }, ) { isExpandedWrapped -> - val composables = remember { + val composables = remember(isExpandedWrapped) { SharedTokenRowComposables( icon = { modifier -> PortfolioSharedAssetIcon( @@ -307,7 +307,7 @@ private fun ForYouPortfolioListHeader( } SpacerWMax() Icon( - imageVector = Icons.ic_error_20, + imageVector = Icons.ic_chevron_collapse_20, tint = TangemTheme.colors3.icon.primary, contentDescription = null, ) 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 e1647620f7..5bb66c7949 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 @@ -10,16 +10,19 @@ 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 +import com.tangem.features.foryou.impl.components.state.DonutSegmentUM +import com.tangem.features.foryou.impl.components.state.MarketChartUM import com.tangem.features.foryou.impl.entity.ForYouTokenListItemUM import com.tangem.features.foryou.impl.entity.PortfolioReviewUM import com.tangem.utils.StringsSigns.DOT import kotlinx.collections.immutable.persistentListOf +import java.math.BigDecimal internal object ForYouPortfolioReviewPreviewData { val reviewContent = PortfolioReviewUM.Content( - assetCount = stringReference("5 assets"), - topHoldingPercent = stringReference("Top holding 42%"), periodPickerUM = TangemSegmentedPickerUM( items = persistentListOf( TangemSegmentUM(id = "0", title = stringReference("Day")), @@ -31,6 +34,27 @@ internal object ForYouPortfolioReviewPreviewData { isAltSurface = true, ), onPeriodClick = {}, + marketChartUM = MarketChartUM.Loaded( + donutChart = DonutChartUM.Loaded( + totalAmount = "10000$", + // Colours are assigned in segment order (rank), matching the transformer's palette-by-index. + donutSegmentList = persistentListOf( + DonutSegmentUM( + color = DonutSegmentColor.Brand, + weight = BigDecimal("0.55"), + title = stringReference("Ethereum"), + fiatValue = stringReference("\$5,720.22"), + ), + DonutSegmentUM( + color = DonutSegmentColor.Green, + weight = BigDecimal("0.45"), + title = stringReference("Solana"), + fiatValue = stringReference("\$728.30"), + ), + ), + ), + topHoldingPercent = stringReference("Top holding 42%"), + ), tokenList = persistentListOf( ForYouTokenListItemUM( tokenRowUM = TangemTokenRowUM.Content( From a561e0f16ccb559cf8c378c6cd3956bac9afa1f0 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 7 Jul 2026 18:12:44 +0500 Subject: [PATCH 2/2] Updated on 2026-08-14 --- .../foryou/impl/model/ForYouModelTest.kt | 192 +++++---------- .../ForYouPortfolioFormattersTest.kt | 83 ++----- .../converter/ForYouTokenListConverterTest.kt | 39 +-- .../converter/ForYouTokenRowConverterTest.kt | 163 ++++++++++++- .../SetPortfolioReviewTransformerTest.kt | 222 +++++++++++++----- 5 files changed, 423 insertions(+), 276 deletions(-) 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 b538927ce3..a484f7b7c7 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 @@ -3,21 +3,19 @@ package com.tangem.features.foryou.impl.model import arrow.core.right import com.google.common.truth.Truth.assertThat import com.tangem.common.test.domain.wallet.MockUserWalletFactory -import com.tangem.common.ui.userwallet.converter.WalletIconUMConverter -import com.tangem.core.ui.ds.image.DeviceIconUM 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.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.common.wallets.UserWalletsListRepository +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.UserWalletIcon import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.wallets.usecase.GetWalletIconUseCase +import com.tangem.features.foryou.impl.components.state.MarketChartUM import com.tangem.features.foryou.impl.entity.PortfolioReviewUM import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.every @@ -36,24 +34,18 @@ import org.junit.jupiter.api.Test import java.math.BigDecimal @OptIn(ExperimentalCoroutinesApi::class) -@Suppress("LargeClass") internal class ForYouModelTest { private val userWalletsListRepository: UserWalletsListRepository = mockk() private val multiAccountStatusListSupplier: MultiAccountStatusListSupplier = mockk() private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase = mockk() - private val walletIconUMConverter: WalletIconUMConverter = mockk { - every { convert(any()) } returns DeviceIconUM.Stub(cardsCount = 1) - } - private val getWalletIconUseCase: GetWalletIconUseCase = mockk() private var model: ForYouModel? = null @BeforeEach fun setup() { - every { getWalletIconUseCase.invoke(any()) } returns UserWalletIcon.Stub(cardsCount = 1) // 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. Individual tests may override. + // actually exercised in every test, not bypassed by an empty flow. every { getSelectedAppCurrencyUseCase() } returns flowOf(AppCurrency.Default.right()) } @@ -67,21 +59,19 @@ internal class ForYouModelTest { inner class InitialState { @Test - fun `GIVEN model created WHEN not yet advanced THEN uiState is Loading`() = runTest { + fun `GIVEN model created WHEN not yet advanced THEN uiState is Loading with skeleton rows`() = runTest { // Arrange - every { userWalletsListRepository.userWallets } returns MutableStateFlow(null) every { userWalletsListRepository.selectedUserWallet } returns MutableStateFlow(null) every { multiAccountStatusListSupplier.invokeAsMap() } returns flowOf(linkedMapOf()) - every { getSelectedAppCurrencyUseCase() } returns flowOf(AppCurrency.Default.right()) // Act val model = createModel(testScope = this) // Assert — before advancing, the model exposes skeleton placeholder rows val loading = model.uiState.value.portfolioReviewUM as PortfolioReviewUM.Loading - assertThat(loading.tokenList).hasSize(4) assertThat(loading.tokenList.all { it.tokenRowUM is TangemTokenRowUM.Loading }).isTrue() + assertThat(loading.marketChartUM).isEqualTo(MarketChartUM.NoData) } } @@ -89,107 +79,42 @@ internal class ForYouModelTest { inner class ContentState { @Test - fun `GIVEN wallets and account statuses emitted WHEN advanced THEN uiState becomes Content with tabs`() = + 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"), + ) + + // Act + val model = createModel(testScope = this) + advanceUntilIdle() + + // Assert + val content = model.uiState.value.portfolioReviewUM as PortfolioReviewUM.Content + assertThat(content.tokenList.map { it.tokenRowUM.id }).containsExactly("btc") + assertThat(content.marketChartUM).isInstanceOf(MarketChartUM.Loaded::class.java) + assertThat(model.uiState.value.notifications).isEmpty() + } + + @Test + fun `GIVEN total balance from outdated source WHEN advanced THEN outdated-data notification is shown`() = runTest { // Arrange - val walletOne = MockUserWalletFactory.create().copy(walletId = UserWalletId("01"), name = "Wallet 1") - val walletTwo = MockUserWalletFactory.create().copy(walletId = UserWalletId("02"), name = "Wallet 2") - every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(walletOne, walletTwo)) - every { userWalletsListRepository.selectedUserWallet } returns MutableStateFlow(walletOne) - val currency = createCoin(rawCurrencyId = "btc", symbol = "BTC") - val accountStatusList = createAccountStatusList( - userWalletId = walletOne.walletId, + stubSelectedWallet( currencies = listOf(createStatus(currency, loadedValue(BigDecimal("100")))), totalFiatBalance = BigDecimal("100"), + source = StatusSource.ONLY_CACHE, ) - every { multiAccountStatusListSupplier.invokeAsMap() } returns flowOf( - linkedMapOf(walletOne.walletId to accountStatusList), - ) - every { getSelectedAppCurrencyUseCase() } returns flowOf() // Act val model = createModel(testScope = this) advanceUntilIdle() // Assert - val content = model.uiState.value.portfolioReviewUM as PortfolioReviewUM.Content - assertThat(content.assetCount).isNotNull() - assertThat(model.uiState.value.walletListUM.items).hasSize(2) - assertThat(model.uiState.value.walletListUM.items.map { it.isSelected }).containsExactly(true, false) - } - - @Test - fun `GIVEN exactly one wallet WHEN advanced THEN walletListUM items is empty`() = runTest { - // Arrange - val wallet = MockUserWalletFactory.create().copy(walletId = UserWalletId("01"), name = "Wallet 1") - every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(wallet)) - every { userWalletsListRepository.selectedUserWallet } returns MutableStateFlow(wallet) - - val accountStatusList = createAccountStatusList( - userWalletId = wallet.walletId, - currencies = emptyList(), - totalFiatBalance = BigDecimal.ZERO, - ) - every { multiAccountStatusListSupplier.invokeAsMap() } returns flowOf( - linkedMapOf(wallet.walletId to accountStatusList), - ) - every { getSelectedAppCurrencyUseCase() } returns flowOf() - - // Act - val model = createModel(testScope = this) - advanceUntilIdle() - - // Assert — the "tabs.size != 1" rule: a single wallet shows no tabs - assertThat(model.uiState.value.walletListUM.items).isEmpty() - } - } - - @Nested - inner class TabClick { - - @Test - fun `GIVEN two wallets WHEN onTabClick THEN locally selected wallet switches and currencies rederive`() = - runTest { - // Arrange - val walletOne = MockUserWalletFactory.create().copy(walletId = UserWalletId("01"), name = "Wallet 1") - val walletTwo = MockUserWalletFactory.create().copy(walletId = UserWalletId("02"), name = "Wallet 2") - every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(walletOne, walletTwo)) - every { userWalletsListRepository.selectedUserWallet } returns MutableStateFlow(walletOne) - - val btc = createCoin(rawCurrencyId = "btc", symbol = "BTC") - val eth = createCoin(rawCurrencyId = "eth", symbol = "ETH") - val statusOne = createAccountStatusList( - userWalletId = walletOne.walletId, - currencies = listOf(createStatus(btc, loadedValue(BigDecimal("100")))), - totalFiatBalance = BigDecimal("100"), - ) - val statusTwo = createAccountStatusList( - userWalletId = walletTwo.walletId, - currencies = listOf(createStatus(eth, loadedValue(BigDecimal("50")))), - totalFiatBalance = BigDecimal("50"), - ) - every { multiAccountStatusListSupplier.invokeAsMap() } returns flowOf( - linkedMapOf(walletOne.walletId to statusOne, walletTwo.walletId to statusTwo), - ) - every { getSelectedAppCurrencyUseCase() } returns flowOf() - - val model = createModel(testScope = this) - advanceUntilIdle() - - // Act — click the second wallet's tab - model.uiState.value.walletListUM.items[1].onClick() - advanceUntilIdle() - - // Assert - assertThat(model.uiState.value.walletListUM.items.map { it.isSelected }).containsExactly( - false, - true, - ).inOrder() - val content = model.uiState.value.portfolioReviewUM as PortfolioReviewUM.Content - val assetRow = content.tokenList.single().tokenRowUM as TangemTokenRowUM.Content - val titleUM = assetRow.titleUM as TangemTokenRowUM.TitleUM.Content - assertThat(titleUM.text).isEqualTo(com.tangem.core.ui.extensions.stringReference("ETH")) + assertThat(model.uiState.value.notifications).containsExactly(ForYouNotification.UsedOutdatedData) } } @@ -199,29 +124,18 @@ internal class ForYouModelTest { @Test fun `GIVEN asset row clicked WHEN clicked again THEN isExpanded toggles back to false`() = runTest { // Arrange - val wallet = MockUserWalletFactory.create().copy(walletId = UserWalletId("01"), name = "Wallet 1") - every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(wallet)) - every { userWalletsListRepository.selectedUserWallet } returns MutableStateFlow(wallet) - val currency = createCoin(rawCurrencyId = "btc", symbol = "BTC") - val accountStatusList = createAccountStatusList( - userWalletId = wallet.walletId, + stubSelectedWallet( currencies = listOf(createStatus(currency, loadedValue(BigDecimal("100")))), totalFiatBalance = BigDecimal("100"), ) - every { multiAccountStatusListSupplier.invokeAsMap() } returns flowOf( - linkedMapOf(wallet.walletId to accountStatusList), - ) - every { getSelectedAppCurrencyUseCase() } returns flowOf() - val model = createModel(testScope = this) advanceUntilIdle() val initialContent = model.uiState.value.portfolioReviewUM as PortfolioReviewUM.Content assertThat(initialContent.tokenList.single().isExpanded).isFalse() // Act — click once to expand - val assetRow = initialContent.tokenList.single().tokenRowUM as TangemTokenRowUM.Content - assetRow.onItemClick?.invoke() + initialContent.assetRow().onItemClick?.invoke() advanceUntilIdle() // Assert @@ -229,8 +143,7 @@ internal class ForYouModelTest { assertThat(expandedContent.tokenList.single().isExpanded).isTrue() // Act — click again to collapse - val expandedRow = expandedContent.tokenList.single().tokenRowUM as TangemTokenRowUM.Content - expandedRow.onItemClick?.invoke() + expandedContent.assetRow().onItemClick?.invoke() advanceUntilIdle() // Assert @@ -246,26 +159,15 @@ internal class ForYouModelTest { fun `GIVEN Content state WHEN period clicked THEN initialSelectedItem updates without resetting rest`() = runTest { // Arrange - val wallet = MockUserWalletFactory.create().copy(walletId = UserWalletId("01"), name = "Wallet 1") - every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(wallet)) - every { userWalletsListRepository.selectedUserWallet } returns MutableStateFlow(wallet) - val currency = createCoin(rawCurrencyId = "btc", symbol = "BTC") - val accountStatusList = createAccountStatusList( - userWalletId = wallet.walletId, + stubSelectedWallet( currencies = listOf(createStatus(currency, loadedValue(BigDecimal("100")))), totalFiatBalance = BigDecimal("100"), ) - every { multiAccountStatusListSupplier.invokeAsMap() } returns flowOf( - linkedMapOf(wallet.walletId to accountStatusList), - ) - every { getSelectedAppCurrencyUseCase() } returns flowOf() - val model = createModel(testScope = this) advanceUntilIdle() val contentBefore = model.uiState.value.portfolioReviewUM as PortfolioReviewUM.Content val weekItem = contentBefore.periodPickerUM.items[1] - val assetCountBefore = contentBefore.assetCount // Act contentBefore.onPeriodClick(weekItem) @@ -273,19 +175,34 @@ internal class ForYouModelTest { // Assert val contentAfter = model.uiState.value.portfolioReviewUM as PortfolioReviewUM.Content assertThat(contentAfter.periodPickerUM.initialSelectedItem).isEqualTo(weekItem) - assertThat(contentAfter.assetCount).isEqualTo(assetCountBefore) assertThat(contentAfter.tokenList).isEqualTo(contentBefore.tokenList) } } + private fun PortfolioReviewUM.Content.assetRow(): TangemTokenRowUM.Content = + tokenList.single().tokenRowUM as TangemTokenRowUM.Content + + /** 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")) + every { userWalletsListRepository.selectedUserWallet } returns MutableStateFlow(wallet) + every { multiAccountStatusListSupplier.invokeAsMap() } returns flowOf( + linkedMapOf( + wallet.walletId to createAccountStatusList(currencies, totalFiatBalance, source), + ), + ) + } + private fun createModel(testScope: TestScope): ForYouModel { return ForYouModel( userWalletsListRepository = userWalletsListRepository, multiAccountStatusListSupplier = multiAccountStatusListSupplier, dispatchers = testScope.createTestingCoroutineDispatcherProvider(), getSelectedAppCurrencyUseCase = getSelectedAppCurrencyUseCase, - walletIconUMConverter = walletIconUMConverter, - getWalletIconUseCase = getWalletIconUseCase, ).also { model = it } } @@ -301,15 +218,14 @@ internal class ForYouModelTest { } private fun createAccountStatusList( - userWalletId: UserWalletId, currencies: List, totalFiatBalance: BigDecimal, + source: StatusSource = StatusSource.ACTUAL, ): AccountStatusList = mockk { - every { this@mockk.userWalletId } returns userWalletId every { flattenCurrencies() } returns currencies every { this@mockk.totalFiatBalance } returns TotalFiatBalance.Loaded( amount = totalFiatBalance, - source = com.tangem.domain.models.StatusSource.ACTUAL, + source = source, ) } @@ -322,6 +238,7 @@ internal class ForYouModelTest { every { amount } returns BigDecimal.ONE every { this@mockk.fiatAmount } returns fiatAmount every { isError } returns false + every { sources } returns CryptoCurrencyStatus.Sources() } private fun createCoin(rawCurrencyId: String, symbol: String): CryptoCurrency.Coin { @@ -337,6 +254,7 @@ 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.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/converter/ForYouPortfolioFormattersTest.kt b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/ForYouPortfolioFormattersTest.kt index 9bc177f833..5b6cce0aea 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/ForYouPortfolioFormattersTest.kt @@ -1,11 +1,6 @@ package com.tangem.features.foryou.impl.model.converter import com.google.common.truth.Truth.assertThat -import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.stringReference -import com.tangem.core.ui.format.bigdecimal.fiat -import com.tangem.core.ui.format.bigdecimal.format -import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import io.mockk.every @@ -16,8 +11,6 @@ import java.math.BigDecimal internal class ForYouPortfolioFormattersTest { - private val appCurrency: AppCurrency = AppCurrency.Default - @Nested inner class ForYouGroupKey { @@ -62,102 +55,66 @@ internal class ForYouPortfolioFormattersTest { } @Nested - inner class ToForYouFiatText { + inner class ToForYouPercent { @Test - fun `GIVEN a fiat amount WHEN toForYouFiatText THEN delegates to fiat formatting`() { - // Arrange - val amount = BigDecimal("1234.5") - - // Act - val result = amount.toForYouFiatText(appCurrency) - - // Assert - val expected = stringReference( - amount.format { fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol) }, - ) - assertThat(result).isEqualTo(expected) - } - - @Test - fun `GIVEN null amount WHEN toForYouFiatText THEN renders dash text`() { + fun `GIVEN null amount WHEN toForYouPercent THEN returns null`() { // Arrange val amount: BigDecimal? = null // Act - val result = amount.toForYouFiatText(appCurrency) + val result = amount.toForYouPercent(BigDecimal("100")) // Assert - val expected = stringReference( - amount.format { fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol) }, - ) - assertThat(result).isEqualTo(expected) - } - } - - @Nested - inner class ToForYouPercentText { - - @Test - fun `GIVEN null amount WHEN toForYouPercentText THEN returns EMPTY`() { - // Arrange - val amount: BigDecimal? = null - - // Act - val result = amount.toForYouPercentText(BigDecimal("100")) - - // Assert - assertThat(result).isEqualTo(TextReference.EMPTY) + assertThat(result).isNull() } @Test - fun `GIVEN zero total WHEN toForYouPercentText THEN returns EMPTY`() { + fun `GIVEN zero total WHEN toForYouPercent THEN returns null`() { // Arrange val amount = BigDecimal("10") // Act - val result = amount.toForYouPercentText(BigDecimal.ZERO) + val result = amount.toForYouPercent(BigDecimal.ZERO) // Assert - assertThat(result).isEqualTo(TextReference.EMPTY) + assertThat(result).isNull() } @Test - fun `GIVEN zero amount WHEN toForYouPercentText THEN returns EMPTY`() { + fun `GIVEN zero amount WHEN toForYouPercent THEN returns null`() { // Arrange val amount = BigDecimal.ZERO // Act - val result = amount.toForYouPercentText(BigDecimal("100")) + val result = amount.toForYouPercent(BigDecimal("100")) // Assert - assertThat(result).isEqualTo(TextReference.EMPTY) + assertThat(result).isNull() } @Test - fun `GIVEN non-zero amount and total WHEN toForYouPercentText THEN returns rounded percent share`() { - // Arrange - val amount = BigDecimal("25.00") - val total = BigDecimal("100") + fun `GIVEN non-zero amount and total WHEN toForYouPercent THEN returns the share as a ratio`() { + // Arrange — 50.00 / 200 = 0.25 (ratio, scaled to the amount's scale) + val amount = BigDecimal("50.00") // Act - val result = amount.toForYouPercentText(total) + val result = amount.toForYouPercent(BigDecimal("200")) - // Assert — 25.00 / 100 = 0.25 -> 25.00% - assertThat(result).isEqualTo(stringReference("25.00%")) + // Assert + assertThat(result).isEqualTo(BigDecimal("0.25")) } @Test - fun `GIVEN a share requiring rounding WHEN toForYouPercentText THEN applies HALF_UP rounding`() { - // Arrange — 1.0000 / 3 = 0.3333... -> rounds to 33.33% + fun `GIVEN a share requiring rounding WHEN toForYouPercent THEN applies HALF_UP rounding`() { + // Arrange — 1.0000 / 3 = 0.3333... rounds HALF_UP to the amount's scale (4) val amount = BigDecimal("1.0000") - val total = BigDecimal("3") // Act - val result = amount.toForYouPercentText(total) + val result = amount.toForYouPercent(BigDecimal("3")) // Assert - assertThat(result).isEqualTo(stringReference("33.33%")) + assertThat(result).isEqualTo(BigDecimal("0.3333")) } } } \ 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 index ff86191938..a36e7bb702 100644 --- 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 @@ -2,6 +2,7 @@ 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.domain.appcurrency.model.AppCurrency @@ -77,7 +78,7 @@ internal class ForYouTokenListConverterTest { val item = result.single() val row = item.tokenRowUM as TangemTokenRowUM.Content val subtitle = row.subtitleUM as TangemTokenRowUM.SubtitleUM.Content - assertThat(subtitle.text).isEqualTo(stringReference("2 networks")) + assertThat(subtitle.text).isEqualTo(pluralReference(R.plurals.common_networks_count, count = 2)) assertThat(item.tokenList).hasSize(2) } @@ -115,13 +116,13 @@ internal class ForYouTokenListConverterTest { } @Test - fun `GIVEN otherAssetCount is zero WHEN convert THEN no Other row is appended`() { + 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"), - otherAssetCount = 0, + otherAssets = emptyList(), ) // Act @@ -132,14 +133,13 @@ internal class ForYouTokenListConverterTest { } @Test - fun `GIVEN otherAssetCount is one WHEN convert THEN Other row subtitle is singular`() { + 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"), - otherAssetCount = 1, - otherFiatBalance = BigDecimal("50"), + otherAssets = listOf(otherAsset(BigDecimal("50"))), ) // Act @@ -149,18 +149,21 @@ internal class ForYouTokenListConverterTest { val otherRow = result.last().tokenRowUM as TangemTokenRowUM.Content assertThat(otherRow.id).isEqualTo("for_you_other_assets") val subtitle = otherRow.subtitleUM as TangemTokenRowUM.SubtitleUM.Content - assertThat(subtitle.text).isEqualTo(stringReference("1 asset")) + assertThat(subtitle.text).isEqualTo(pluralReference(R.plurals.common_assets, count = 1)) } @Test - fun `GIVEN otherAssetCount is more than one WHEN convert THEN Other row subtitle is plural`() { + 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"), - otherAssetCount = 3, - otherFiatBalance = BigDecimal("50"), + otherAssets = listOf( + otherAsset(BigDecimal("30")), + otherAsset(BigDecimal("15")), + otherAsset(BigDecimal("5")), + ), ) // Act @@ -169,7 +172,7 @@ internal class ForYouTokenListConverterTest { // Assert val otherRow = result.last().tokenRowUM as TangemTokenRowUM.Content val subtitle = otherRow.subtitleUM as TangemTokenRowUM.SubtitleUM.Content - assertThat(subtitle.text).isEqualTo(stringReference("3 assets")) + assertThat(subtitle.text).isEqualTo(pluralReference(R.plurals.common_assets, count = 3)) } @Test @@ -210,17 +213,22 @@ internal class ForYouTokenListConverterTest { private fun createConverter( totalFiatBalance: BigDecimal, expandedAssetIds: Set = emptySet(), - otherAssetCount: Int = 0, - otherFiatBalance: BigDecimal = BigDecimal.ZERO, + otherAssets: List, BigDecimal>> = emptyList(), ): ForYouTokenListConverter = ForYouTokenListConverter( appCurrency = appCurrency, totalFiatBalance = totalFiatBalance, expandedAssetIds = expandedAssetIds, expandClick = {}, - otherAssetCount = otherAssetCount, - otherFiatBalance = otherFiatBalance, + otherAssets = otherAssets, ) + /** + * 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, @@ -230,6 +238,7 @@ internal class ForYouTokenListConverterTest { 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 { 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/ForYouTokenRowConverterTest.kt index c9a0a73940..9b9e46353f 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/ForYouTokenRowConverterTest.kt @@ -2,7 +2,13 @@ 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.TextReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.format.bigdecimal.fiat +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.format.bigdecimal.percent import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.StatusSource import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.network.Network @@ -49,8 +55,8 @@ internal class ForYouTokenRowConverterTest { assertThat(result.id).isEqualTo("coin-eth") val topEnd = result.topEndContentUM as TangemTokenRowUM.EndContentUM.Content val bottomEnd = result.bottomEndContentUM as TangemTokenRowUM.EndContentUM.Content - assertThat(topEnd.text).isEqualTo(BigDecimal("400").toForYouFiatText(appCurrency)) - assertThat(bottomEnd.text).isEqualTo(BigDecimal("400").toForYouPercentText(BigDecimal("1000"))) + assertThat(topEnd.text).isEqualTo(BigDecimal("400").expectedFiatText()) + assertThat(bottomEnd.text).isEqualTo(BigDecimal("400").expectedPercentText(BigDecimal("1000"))) } @Test @@ -68,7 +74,7 @@ internal class ForYouTokenRowConverterTest { // Assert val topEnd = result.topEndContentUM as TangemTokenRowUM.EndContentUM.Content - assertThat(topEnd.text).isEqualTo(BigDecimal("600").toForYouFiatText(appCurrency)) + assertThat(topEnd.text).isEqualTo(BigDecimal("600").expectedFiatText()) } @Test @@ -87,6 +93,124 @@ internal class ForYouTokenRowConverterTest { // Assert assertThat(result).isInstanceOf(TangemTokenRowUM.Content::class.java) } + + @Test + fun `GIVEN loaded status from cache WHEN convertNetworkGroup THEN content flickers`() { + // Arrange + val currency = createCurrency(id = "coin-eth", symbol = "ETH", networkName = "Ethereum") + val statuses = listOf( + createStatus( + currency, + loadedValue(amount = BigDecimal("1"), fiatAmount = BigDecimal("100"), source = StatusSource.CACHE), + ), + ) + val converter = createConverter(totalFiatBalance = BigDecimal("1000")) + + // Act + val result = converter.convertNetworkGroup(statuses) as TangemTokenRowUM.Content + + // Assert + val topEnd = result.topEndContentUM as TangemTokenRowUM.EndContentUM.Content + val bottomEnd = result.bottomEndContentUM as TangemTokenRowUM.EndContentUM.Content + assertThat(topEnd.isFlickering).isTrue() + assertThat(bottomEnd.isFlickering).isTrue() + assertThat(topEnd.startIcons).isEmpty() + } + + @Test + fun `GIVEN loaded status only-cache WHEN convertNetworkGroup THEN error-sync start icon shown`() { + // Arrange + val currency = createCurrency(id = "coin-eth", symbol = "ETH", networkName = "Ethereum") + val statuses = listOf( + createStatus( + currency, + loadedValue( + amount = BigDecimal("1"), + fiatAmount = BigDecimal("100"), + source = StatusSource.ONLY_CACHE, + ), + ), + ) + val converter = createConverter(totalFiatBalance = BigDecimal("1000")) + + // Act + val result = converter.convertNetworkGroup(statuses) as TangemTokenRowUM.Content + + // Assert + val topEnd = result.topEndContentUM as TangemTokenRowUM.EndContentUM.Content + assertThat(topEnd.isFlickering).isFalse() + assertThat(topEnd.startIcons).hasSize(1) + } + + @Test + fun `GIVEN missed derivation status WHEN convertNetworkGroup THEN no-address treatment`() { + // Arrange + val currency = createCurrency(id = "coin-eth", symbol = "ETH", networkName = "Ethereum") + val statuses = listOf(createStatus(currency, missedDerivationValue())) + val converter = createConverter(totalFiatBalance = BigDecimal("1000")) + + // Act + val result = converter.convertNetworkGroup(statuses) as TangemTokenRowUM.Content + + // Assert — top-end is a dash, bottom-end carries the attention "no address" icon + val topEnd = result.topEndContentUM as TangemTokenRowUM.EndContentUM.Content + val bottomEnd = result.bottomEndContentUM as TangemTokenRowUM.EndContentUM.Content + assertThat(topEnd.endIcons).isEmpty() + assertThat(bottomEnd.endIcons).hasSize(1) + } + + @Test + fun `GIVEN unreachable status WHEN convertNetworkGroup THEN dash on top and attention icon on bottom`() { + // Arrange + val currency = createCurrency(id = "coin-eth", symbol = "ETH", networkName = "Ethereum") + val statuses = listOf(createStatus(currency, unreachableValue())) + val converter = createConverter(totalFiatBalance = BigDecimal("1000")) + + // Act + val result = converter.convertNetworkGroup(statuses) as TangemTokenRowUM.Content + + // Assert — top-end is a bare dash, the attention "unreachable" icon lives on the bottom end + val topEnd = result.topEndContentUM as TangemTokenRowUM.EndContentUM.Content + val bottomEnd = result.bottomEndContentUM as TangemTokenRowUM.EndContentUM.Content + assertThat(topEnd.endIcons).isEmpty() + assertThat(bottomEnd.endIcons).hasSize(1) + } + + @Test + fun `GIVEN mixed Loaded and Unreachable WHEN convertNetworkGroup THEN collapses to unreachable`() { + // Arrange — one account resolved, another unreachable: the row must surface the error state + val currency = createCurrency(id = "coin-eth", symbol = "ETH", networkName = "Ethereum") + val statuses = listOf( + createStatus(currency, loadedValue(amount = BigDecimal("1"), fiatAmount = BigDecimal("100"))), + createStatus(currency, unreachableValue()), + ) + val converter = createConverter(totalFiatBalance = BigDecimal("1000")) + + // Act + val result = converter.convertNetworkGroup(statuses) as TangemTokenRowUM.Content + + // Assert — the unreachable treatment (attention icon on the bottom end) wins over the loaded amount + val bottomEnd = result.bottomEndContentUM as TangemTokenRowUM.EndContentUM.Content + assertThat(bottomEnd.endIcons).hasSize(1) + } + + @Test + fun `GIVEN mixed MissedDerivation and Unreachable WHEN convertNetworkGroup THEN missed-derivation wins`() { + // Arrange — missed derivation is the most severe terminal state and dominates + val currency = createCurrency(id = "coin-eth", symbol = "ETH", networkName = "Ethereum") + val statuses = listOf( + createStatus(currency, unreachableValue()), + createStatus(currency, missedDerivationValue()), + ) + val converter = createConverter(totalFiatBalance = BigDecimal("1000")) + + // Act + val result = converter.convertNetworkGroup(statuses) as TangemTokenRowUM.Content + + // Assert — top-end is a dash (no-address treatment), not an unreachable label + val topEnd = result.topEndContentUM as TangemTokenRowUM.EndContentUM.Content + assertThat(topEnd.endIcons).isEmpty() + } } private fun createConverter(totalFiatBalance: BigDecimal) = ForYouTokenRowConverter( @@ -94,15 +218,46 @@ internal class ForYouTokenRowConverterTest { totalFiatBalance = totalFiatBalance, ) + /** Mirrors the production fiat rendering used by [ForYouTokenRowConverter] 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. */ + private fun BigDecimal.expectedPercentText(total: BigDecimal): TextReference = stringReference( + toForYouPercent(total).format { percent() }, + ) + private fun createStatus(currency: CryptoCurrency, value: CryptoCurrencyStatus.Value) = CryptoCurrencyStatus( currency = currency, value = value, ) - private fun loadedValue(amount: BigDecimal, fiatAmount: BigDecimal): CryptoCurrencyStatus.Loaded = mockk { + private fun loadedValue( + amount: BigDecimal, + fiatAmount: BigDecimal, + source: StatusSource = StatusSource.ACTUAL, + ): CryptoCurrencyStatus.Loaded = mockk { every { this@mockk.amount } returns amount every { this@mockk.fiatAmount } returns fiatAmount every { isError } returns false + every { sources } returns CryptoCurrencyStatus.Sources( + networkSource = source, + quoteSource = source, + stakingBalanceSource = source, + ) + } + + private fun missedDerivationValue(): CryptoCurrencyStatus.MissedDerivation = mockk { + every { amount } returns null + every { fiatAmount } returns null + every { isError } returns true + } + + private fun unreachableValue(): CryptoCurrencyStatus.Unreachable = mockk { + every { amount } returns null + every { fiatAmount } returns null + every { isError } returns true } private fun createCurrency( 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 76ddeda041..ad263cedb4 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 @@ -2,14 +2,18 @@ package com.tangem.features.foryou.impl.model.transformer import com.google.common.truth.Truth.assertThat 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.commonfeatures.api.choosetoken.model.WalletListUM +import com.tangem.features.foryou.impl.components.state.MarketChartUM 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 import io.mockk.every import io.mockk.mockk import kotlinx.collections.immutable.persistentListOf @@ -20,13 +24,12 @@ import java.math.BigDecimal internal class SetPortfolioReviewTransformerTest { private val appCurrency: AppCurrency = AppCurrency.Default - private val walletListUM = WalletListUM(items = persistentListOf()) @Nested - inner class Transform { + inner class TokenList { @Test - fun `GIVEN currency with zero fiat balance WHEN transform THEN it is dropped from asset count`() { + fun `GIVEN currency with resolved zero fiat balance WHEN transform THEN it is dropped from the list`() { // Arrange val zeroBalance = createCurrency(rawCurrencyId = "btc", symbol = "BTC") val nonZeroBalance = createCurrency(rawCurrencyId = "eth", symbol = "ETH") @@ -34,18 +37,37 @@ internal class SetPortfolioReviewTransformerTest { createStatus(zeroBalance, loadedValue(BigDecimal.ZERO)), createStatus(nonZeroBalance, loadedValue(BigDecimal("100"))), ) - val transformer = createTransformer(currencies = currencies, totalFiatBalance = BigDecimal("100")) + val transformer = createTransformer(accountStatusList(currencies, loaded(BigDecimal("100")))) // Act val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content - // Assert - assertThat(result.assetCount).isEqualTo(stringReference("1 assets")) + // Assert — only the ETH asset survives; the zero-fiat BTC is dropped + assertThat(result.tokenList.map { it.tokenRowUM.id }).containsExactly("eth") } @Test - fun `GIVEN assets across networks WHEN transform THEN they are aggregated and ranked by summed fiat`() { - // Arrange — same asset (rawCurrencyId "usdc") on two networks aggregates into one asset + 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") @@ -54,13 +76,13 @@ internal class SetPortfolioReviewTransformerTest { createStatus(onSol, loadedValue(BigDecimal("60"))), createStatus(other, loadedValue(BigDecimal("10"))), ) - val transformer = createTransformer(currencies = currencies, totalFiatBalance = BigDecimal("120")) + 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) and btc (10) - assertThat(result.assetCount).isEqualTo(stringReference("2 assets")) + // Assert — 2 ranked assets: usdc (110 total) ahead of btc (10) + assertThat(result.tokenList.map { it.tokenRowUM.id }).containsExactly("usdc", "btc").inOrder() } @Test @@ -72,12 +94,12 @@ internal class SetPortfolioReviewTransformerTest { loadedValue(BigDecimal(100 - index)), ) } - val transformer = createTransformer(currencies = currencies, totalFiatBalance = BigDecimal("470")) + val transformer = createTransformer(accountStatusList(currencies, loaded(BigDecimal("470")))) // Act val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content - // Assert — tokenList has 4 top asset rows + 1 "Other" row = 5 items + // Assert — 4 top asset rows + 1 "Other" row assertThat(result.tokenList).hasSize(5) assertThat(result.tokenList.last().tokenRowUM.id).isEqualTo("for_you_other_assets") } @@ -91,7 +113,7 @@ internal class SetPortfolioReviewTransformerTest { loadedValue(BigDecimal(100 - index)), ) } - val transformer = createTransformer(currencies = currencies, totalFiatBalance = BigDecimal("394")) + val transformer = createTransformer(accountStatusList(currencies, loaded(BigDecimal("394")))) // Act val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content @@ -101,39 +123,76 @@ internal class SetPortfolioReviewTransformerTest { } @Test - fun `GIVEN total and top balance non-zero WHEN transform THEN topHoldingPercent is computed`() { - // Arrange — a single asset means top balance == total balance == 100% - val currency = createCurrency(rawCurrencyId = "btc", symbol = "BTC") - val currencies = listOf(createStatus(currency, loadedValue(BigDecimal("100")))) - val transformer = createTransformer(currencies = currencies, totalFiatBalance = BigDecimal("100")) + 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.topHoldingPercent).isEqualTo(stringReference("Top holding 100.00%")) + 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 zero total fiat balance WHEN transform THEN topHoldingPercent is DASH_SIGN`() { + fun `GIVEN non-loaded total balance WHEN transform THEN market chart is NoData`() { // Arrange - val currency = createCurrency(rawCurrencyId = "btc", symbol = "BTC") - val currencies = listOf(createStatus(currency, loadedValue(BigDecimal.ZERO))) - val transformer = createTransformer(currencies = currencies, totalFiatBalance = BigDecimal.ZERO) + 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.topHoldingPercent).isEqualTo(stringReference("Top holding —")) + 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) + } + } + + @Nested + inner class PeriodPicker { + @Test fun `GIVEN prev state is Loading WHEN transform THEN period picker is freshly created with Day selected`() { // Arrange - val currency = createCurrency(rawCurrencyId = "btc", symbol = "BTC") - val currencies = listOf(createStatus(currency, loadedValue(BigDecimal("10")))) - val transformer = createTransformer(currencies = currencies, totalFiatBalance = BigDecimal("10")) + val currencies = listOf( + createStatus(createCurrency(rawCurrencyId = "btc", symbol = "BTC"), loadedValue(BigDecimal("10"))), + ) + val transformer = createTransformer(accountStatusList(currencies, loaded(BigDecimal("10")))) // Act val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content @@ -150,15 +209,17 @@ internal class SetPortfolioReviewTransformerTest { @Test fun `GIVEN prev state is Content WHEN transform THEN period picker selection is preserved`() { // Arrange - val currency = createCurrency(rawCurrencyId = "btc", symbol = "BTC") - val currencies = listOf(createStatus(currency, loadedValue(BigDecimal("10")))) - val transformer = createTransformer(currencies = currencies, totalFiatBalance = BigDecimal("10")) - val prevContentState = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content - val weekItem = prevContentState.periodPickerUM.items[1] - val prevWithWeekSelected = prevContentState.copy( - periodPickerUM = prevContentState.periodPickerUM.copy(initialSelectedItem = weekItem), + val currencies = listOf( + createStatus(createCurrency(rawCurrencyId = "btc", symbol = "BTC"), loadedValue(BigDecimal("10"))), + ) + 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), + ), ) - val prevState = ForYouUM(walletListUM = walletListUM, portfolioReviewUM = prevWithWeekSelected) // Act val result = transformer.transform(prevState).portfolioReviewUM as PortfolioReviewUM.Content @@ -166,48 +227,86 @@ internal class SetPortfolioReviewTransformerTest { // Assert assertThat(result.periodPickerUM.initialSelectedItem).isEqualTo(weekItem) } + } + + @Nested + inner class Notifications { @Test - fun `GIVEN new state WHEN transform THEN walletListUM is applied from constructor`() { + fun `GIVEN total balance from outdated source WHEN transform THEN outdated-data notification is emitted`() { // Arrange - val currency = createCurrency(rawCurrencyId = "btc", symbol = "BTC") - val currencies = listOf(createStatus(currency, loadedValue(BigDecimal("10")))) - val newWalletListUM = WalletListUM(items = persistentListOf()) - val transformer = SetPortfolioReviewTransformer( - walletListUM = newWalletListUM, - currencies = currencies, - totalFiatBalance = BigDecimal("10"), - appCurrency = appCurrency, - expandedAssetIds = emptySet(), - expandClick = {}, - onPeriodClick = {}, + val currencies = listOf( + createStatus(createCurrency(rawCurrencyId = "btc", symbol = "BTC"), loadedValue(BigDecimal("10"))), + ) + val transformer = createTransformer( + accountStatusList(currencies, loaded(BigDecimal("10"), source = StatusSource.ONLY_CACHE)), ) // Act val result = transformer.transform(loadingState()) // Assert - assertThat(result.walletListUM).isSameInstanceAs(newWalletListUM) + assertThat(result.notifications).containsExactly(ForYouNotification.UsedOutdatedData) + } + + @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)), + ) + + // Act + val result = transformer.transform(loadingState()) + + // Assert + assertThat(result.notifications).isEmpty() + } + + @Test + fun `GIVEN null account status list WHEN transform THEN no notification is emitted`() { + // Arrange + val transformer = createTransformer(accountStatusList = null) + + // Act + val result = transformer.transform(loadingState()) + + // Assert + assertThat(result.notifications).isEmpty() } } private fun createTransformer( - currencies: List, - totalFiatBalance: BigDecimal, + accountStatusList: AccountStatusList?, expandedAssetIds: Set = emptySet(), ) = SetPortfolioReviewTransformer( - walletListUM = walletListUM, - currencies = currencies, - totalFiatBalance = totalFiatBalance, + accountStatusList = accountStatusList, appCurrency = appCurrency, expandedAssetIds = expandedAssetIds, expandClick = {}, onPeriodClick = {}, ) + private fun accountStatusList( + currencies: List, + totalFiatBalance: TotalFiatBalance, + ): AccountStatusList = mockk { + every { flattenCurrencies() } returns currencies + every { this@mockk.totalFiatBalance } returns totalFiatBalance + } + + private fun loaded(amount: BigDecimal, source: StatusSource = StatusSource.ACTUAL): TotalFiatBalance.Loaded = + TotalFiatBalance.Loaded(amount = amount, source = source) + private fun loadingState(): ForYouUM = ForYouUM( - walletListUM = walletListUM, - portfolioReviewUM = PortfolioReviewUM.Loading(tokenList = persistentListOf()), + portfolioReviewUM = PortfolioReviewUM.Loading( + tokenList = persistentListOf(), + marketChartUM = MarketChartUM.NoData, + ), + notifications = persistentListOf(), ) private fun createStatus(currency: CryptoCurrency, value: CryptoCurrencyStatus.Value) = CryptoCurrencyStatus( @@ -219,8 +318,16 @@ internal class SetPortfolioReviewTransformerTest { 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" @@ -234,6 +341,7 @@ internal class SetPortfolioReviewTransformerTest { 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