Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-08 17:07:46 +05:00
commit e726f26dcf
19 changed files with 829 additions and 529 deletions

View file

@ -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,

View file

@ -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<ForYouNotification>,
)
@Immutable
internal sealed interface PortfolioReviewUM {
val tokenList: ImmutableList<ForYouTokenListItemUM>
val marketChartUM: MarketChartUM
data class Loading(
override val tokenList: ImmutableList<ForYouTokenListItemUM>,
override val marketChartUM: MarketChartUM.NoData,
) : PortfolioReviewUM
data class Content(
override val tokenList: ImmutableList<ForYouTokenListItemUM>,
override val marketChartUM: MarketChartUM,
val periodPickerUM: TangemSegmentedPickerUM,
val assetCount: TextReference,
val topHoldingPercent: TextReference,
val onPeriodClick: (TangemSegmentUM) -> Unit,
) : PortfolioReviewUM
}

View file

@ -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<UserWalletId?>(value = null)
private val expandedAssetIds = MutableStateFlow<Set<String>>(value = emptySet())
private val selectedAppCurrencyFlow: StateFlow<AppCurrency> = createSelectedAppCurrencyFlow()
val uiState: StateFlow<ForYouUM>
field = MutableStateFlow<ForYouUM>(
ForYouUM(
walletListUM = WalletListUM(persistentListOf()),
notifications = persistentListOf(),
portfolioReviewUM = PortfolioReviewUM.Loading(
marketChartUM = MarketChartUM.NoData,
tokenList = buildList<ForYouTokenListItemUM> {
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

View file

@ -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,
),
)
}

View file

@ -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<Pair<List<CryptoCurrencyStatus>, BigDecimal>>,
) : Converter<TotalFiatBalance?, MarketChartUM> {
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
}
}
}

View file

@ -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.

View file

@ -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<String>,
private val expandClick: (assetId: String) -> Unit,
private val otherAssetCount: Int,
private val otherFiatBalance: BigDecimal,
private val otherAssets: List<Pair<List<CryptoCurrencyStatus>, BigDecimal>>,
) : Converter<List<CryptoCurrencyStatus>, ImmutableList<ForYouTokenListItemUM>> {
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"

View file

@ -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<CryptoCurrencyStatus>, 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<CryptoCurrencyStatus>.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<StatusSource>.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
}
}

View file

@ -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<CryptoCurrencyStatus>,
private val totalFiatBalance: BigDecimal,
private val accountStatusList: AccountStatusList?,
private val appCurrency: AppCurrency,
private val expandedAssetIds: Set<String>,
private val expandClick: (assetId: String) -> Unit,
@ -42,10 +39,18 @@ internal class SetPortfolioReviewTransformer(
) : Transformer<ForYouUM> {
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"))

View file

@ -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<ForYouUM>
override val values: Sequence<ForYouUM>
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,
),
)

View file

@ -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<Po
get() = sequenceOf(
ForYouPortfolioReviewPreviewData.reviewContent,
PortfolioReviewUM.Loading(
marketChartUM = MarketChartUM.NoData,
tokenList = buildList {
repeat(4) { index ->
add(

View file

@ -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),
)
}
}
}
}

View file

@ -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,
)

View file

@ -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(

View file

@ -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<CryptoCurrencyStatus>,
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<CryptoCurrencyStatus>,
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<CryptoCurrency.Coin> {
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

View file

@ -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"))
}
}
}

View file

@ -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<String> = emptySet(),
otherAssetCount: Int = 0,
otherFiatBalance: BigDecimal = BigDecimal.ZERO,
otherAssets: List<Pair<List<CryptoCurrencyStatus>, 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<List<CryptoCurrencyStatus>, BigDecimal> =
emptyList<CryptoCurrencyStatus>() 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 {

View file

@ -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(

View file

@ -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<CryptoCurrencyStatus>,
totalFiatBalance: BigDecimal,
accountStatusList: AccountStatusList?,
expandedAssetIds: Set<String> = emptySet(),
) = SetPortfolioReviewTransformer(
walletListUM = walletListUM,
currencies = currencies,
totalFiatBalance = totalFiatBalance,
accountStatusList = accountStatusList,
appCurrency = appCurrency,
expandedAssetIds = expandedAssetIds,
expandClick = {},
onPeriodClick = {},
)
private fun accountStatusList(
currencies: List<CryptoCurrencyStatus>,
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<ForYouTokenListItemUM>()),
portfolioReviewUM = PortfolioReviewUM.Loading(
tokenList = persistentListOf<ForYouTokenListItemUM>(),
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<CryptoCurrency.Coin> {
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