Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-16 11:48:37 +05:00
commit 3e82313e52
17 changed files with 402 additions and 109 deletions

View file

@ -908,9 +908,11 @@
<item quantity="one">%d asset</item>
<item quantity="other">%d assets</item>
</plurals>
<string name="market_chart_bubble_no_amount">No amount on tokens</string>
<string name="market_chart_bubble_no_data">No data</string>
<string name="market_chart_bubble_total_value">Total value</string>
<string name="market_chart_can_not_load_data">Cant load data</string>
<string name="market_chart_no_amount">You dont have any tokens with amount</string>
<string name="market_chart_top_holding">Top holding %s</string>
<string name="markets_about_coin_header">About coin</string>
<string name="markets_add_to_my_portfolio_description">To buy, exchange, or receive this asset, add it to your portfolio</string>

View file

@ -69,7 +69,14 @@ internal class DefaultForYouComponent @AssistedInject constructor(
childFactory = { config, componentContext ->
when (config) {
ForYouBottomSheetConfig.AddToPortfolio -> portfolioSelectorChild(componentContext)
is ForYouBottomSheetConfig.ManageFunds -> manageFundsChild(config, componentContext)
is ForYouBottomSheetConfig.ManageFunds -> manageFundsChild(
componentContext = componentContext,
launchMode = ManageFundsComponent.LaunchMode.FilteredByRawId(config.rawCurrencyId),
)
is ForYouBottomSheetConfig.AddFunds -> manageFundsChild(
componentContext = componentContext,
launchMode = ManageFundsComponent.LaunchMode.ChooseToken(config.userWalletId),
)
}
},
)
@ -129,12 +136,12 @@ internal class DefaultForYouComponent @AssistedInject constructor(
)
private fun manageFundsChild(
config: ForYouBottomSheetConfig.ManageFunds,
launchMode: ManageFundsComponent.LaunchMode,
componentContext: ComponentContext,
): ComposableBottomSheetComponent = manageFundsComponentFactory.create(
context = childByContext(componentContext),
params = ManageFundsComponent.Params(
launchMode = ManageFundsComponent.LaunchMode.FilteredByRawId(config.rawCurrencyId),
launchMode = launchMode,
onDismiss = { model.bottomSheetNavigation.dismiss() },
),
)

View file

@ -16,6 +16,7 @@ import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
@ -51,13 +52,16 @@ internal fun MarketChart(marketChart: MarketChartUM, modifier: Modifier = Modifi
Column(modifier = Modifier.fillMaxWidth()) {
DonutChartBlock(marketChart.donutChart, cardBoundsInWindow)
Spacer(modifier = Modifier.height(16.dp))
if (marketChart is MarketChartUM.Loaded) {
TopHoldingBlock(
assetCount = marketChart.assetCount,
topHoldingPercent = marketChart.topHoldingPercent,
)
} else {
CantLoadDataBlock()
when (marketChart) {
is MarketChartUM.Loaded -> {
TopHoldingBlock(
assetCount = marketChart.assetCount,
topHoldingPercent = marketChart.topHoldingPercent,
)
}
is MarketChartUM.NoData -> {
CantLoadDataBlock(text = marketChart.title)
}
}
Spacer(modifier = Modifier.height(16.dp))
@ -113,36 +117,40 @@ private fun ColumnScope.DonutChartBlock(donutChartUM: DonutChartUM, cardBoundsIn
},
segments = segments,
) {
if (donutChartUM is DonutChartUM.Loaded) {
Text(
text = donutChartUM.totalAmount,
color = TangemTheme.colors3.text.primary,
style = TangemTheme.typography3.body.medium,
maxLines = 1,
autoSize = TextAutoSize.StepBased(
minFontSize = 8.sp,
maxFontSize = TangemTheme.typography3.body.medium.fontSize,
),
)
Text(
text = stringResourceSafe(R.string.market_chart_bubble_total_value),
color = TangemTheme.colors3.text.secondary,
style = TangemTheme.typography3.caption.medium,
maxLines = 1,
autoSize = TextAutoSize.StepBased(
maxFontSize = TangemTheme.typography3.caption.medium.fontSize,
),
)
} else {
Text(
text = stringResourceSafe(R.string.market_chart_bubble_no_data),
color = TangemTheme.colors3.text.secondary,
style = TangemTheme.typography3.body.medium,
maxLines = 1,
autoSize = TextAutoSize.StepBased(
maxFontSize = TangemTheme.typography3.caption.medium.fontSize,
),
)
when (donutChartUM) {
is DonutChartUM.Loaded -> {
Text(
text = donutChartUM.totalAmount,
color = TangemTheme.colors3.text.primary,
style = TangemTheme.typography3.body.medium,
maxLines = 1,
autoSize = TextAutoSize.StepBased(
minFontSize = 8.sp,
maxFontSize = TangemTheme.typography3.body.medium.fontSize,
),
)
Text(
text = stringResourceSafe(R.string.market_chart_bubble_total_value),
color = TangemTheme.colors3.text.secondary,
style = TangemTheme.typography3.caption.medium,
maxLines = 1,
autoSize = TextAutoSize.StepBased(
maxFontSize = TangemTheme.typography3.caption.medium.fontSize,
),
)
}
is DonutChartUM.NoData -> {
Text(
text = donutChartUM.title.resolveReference(),
color = TangemTheme.colors3.text.secondary,
style = TangemTheme.typography3.body.medium,
maxLines = 2,
textAlign = TextAlign.Center,
autoSize = TextAutoSize.StepBased(
maxFontSize = TangemTheme.typography3.caption.medium.fontSize,
),
)
}
}
}
@ -233,10 +241,10 @@ private fun ColumnScope.TopHoldingBlock(assetCount: Int, topHoldingPercent: Text
}
@Composable
private fun ColumnScope.CantLoadDataBlock() {
private fun ColumnScope.CantLoadDataBlock(text: TextReference) {
Text(
modifier = Modifier.padding(horizontal = 16.dp),
text = stringResourceSafe(R.string.market_chart_can_not_load_data),
text = text.resolveReference(),
color = TangemTheme.colors3.text.secondary,
style = TangemTheme.typography3.heading.small,
)
@ -306,7 +314,10 @@ private fun previewMarketChartState(scenario: MarketChartPreviewScenario): Marke
),
),
)
MarketChartPreviewScenario.NO_DATA -> MarketChartUM.NoData
MarketChartPreviewScenario.NO_DATA -> MarketChartUM.NoData(
title = resourceReference(R.string.market_chart_can_not_load_data),
donutText = resourceReference(R.string.market_chart_bubble_no_data),
)
}
@Suppress("MagicNumber")

View file

@ -21,8 +21,11 @@ internal sealed class MarketChartUM(
val assetCount: Int = donutChart.donutSegmentList.size
}
data object NoData : MarketChartUM(
donutChart = DonutChartUM.NoData,
data class NoData(
val title: TextReference,
private val donutText: TextReference,
) : MarketChartUM(
donutChart = DonutChartUM.NoData(title = donutText),
aiInsight = AiInsightUM.Hide,
)
}
@ -36,7 +39,9 @@ internal sealed class DonutChartUM(
override val donutSegmentList: ImmutableList<DonutSegmentUM>,
) : DonutChartUM(donutSegmentList = donutSegmentList)
data object NoData : DonutChartUM(donutSegmentList = persistentListOf())
data class NoData(
val title: TextReference,
) : DonutChartUM(donutSegmentList = persistentListOf())
}
@Immutable

View file

@ -1,6 +1,7 @@
package com.tangem.features.foryou.impl.entity
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
internal sealed interface ForYouBottomSheetConfig {
@ -9,4 +10,8 @@ internal sealed interface ForYouBottomSheetConfig {
data class ManageFunds(
val rawCurrencyId: CryptoCurrency.RawID,
) : ForYouBottomSheetConfig
data class AddFunds(
val userWalletId: UserWalletId,
) : ForYouBottomSheetConfig
}

View file

@ -31,6 +31,7 @@ internal sealed interface PortfolioReviewUM {
data class Content(
override val tokenList: ImmutableList<ForYouTokenListItemUM>,
override val marketChartUM: MarketChartUM,
val onAddFundsClick: (() -> Unit)?,
) : PortfolioReviewUM
}

View file

@ -16,6 +16,7 @@ import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.ui.ds.row.token.TangemTokenRowUM
import com.tangem.core.ui.ds.tabs.TangemSegmentUM
import com.tangem.core.ui.ds.tabs.TangemSegmentedPickerUM
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier
import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
@ -32,6 +33,7 @@ import com.tangem.domain.staking.usecase.StakingAvailabilityListUseCase
import com.tangem.domain.yield.supply.usecase.YieldSupplyApyFlowUseCase
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager
import com.tangem.features.foryou.ForYouComponent
import com.tangem.features.foryou.impl.R
import com.tangem.features.foryou.impl.components.state.MarketChartUM
import com.tangem.features.foryou.impl.entity.*
import com.tangem.features.foryou.impl.model.converter.TOP_EARN_TOKENS_BATCH_SIZE
@ -106,7 +108,10 @@ internal class ForYouModel @Inject constructor(
),
onPeriodClick = ::onPeriodClick,
portfolioReviewUM = PortfolioReviewUM.Loading(
marketChartUM = MarketChartUM.NoData,
marketChartUM = MarketChartUM.NoData(
title = resourceReference(R.string.market_chart_can_not_load_data),
donutText = resourceReference(R.string.market_chart_bubble_no_data),
),
tokenList = buildList<ForYouTokenListItemUM> {
repeat(4) { index ->
add(
@ -154,6 +159,7 @@ internal class ForYouModel @Inject constructor(
expandedAssetIds = expandedPortfolioReview,
expandClick = ::onExpandPortfolioReviewClick,
onTokenClick = ::onPortfolioReviewTokenClick,
onAddFundsClick = ::onAddFundsClick,
).convert(accountStatusList)
val earnOpportunitiesUM = ForYouEarnOpportunitiesConverter(
@ -363,4 +369,8 @@ internal class ForYouModel @Inject constructor(
},
)
}
private fun onAddFundsClick(userWalletId: UserWalletId) {
bottomSheetNavigation.activate(ForYouBottomSheetConfig.AddFunds(userWalletId))
}
}

View file

@ -15,8 +15,10 @@ import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.foryou.impl.R
import com.tangem.features.foryou.impl.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.model.converter.FOR_YOU_TOP_EARN_TOKENS_COUNT
import com.tangem.features.foryou.impl.model.converter.forYouGroupKey
import com.tangem.features.foryou.impl.model.converter.forYouPlaceholderBadge
import com.tangem.features.foryou.impl.model.converter.toForYouPercent
@ -42,6 +44,7 @@ internal class ForYouPortfolioReviewConverter(
private val expandedAssetIds: Set<String>,
private val expandClick: (assetId: String) -> Unit,
private val onTokenClick: (UserWalletId, CryptoCurrency) -> Unit,
private val onAddFundsClick: (UserWalletId) -> Unit,
) : Converter<AccountStatusList?, PortfolioReviewUM> {
private val iconConverter = CryptoCurrencyToIconStateConverter()
@ -51,6 +54,26 @@ internal class ForYouPortfolioReviewConverter(
val loadedBalance = value?.totalFiatBalance as? TotalFiatBalance.Loaded
val totalFiatBalance = loadedBalance?.amount.orZero()
if (currencies.all { it.value.fiatAmount?.isZero() == true }) {
return PortfolioReviewUM.Content(
tokenList = currencies.take(FOR_YOU_TOP_EARN_TOKENS_COUNT)
.groupBy { it.forYouGroupKey() }
.map { (assetId, currencies) ->
createListItem(
userWalletId = value?.userWalletId,
assetId = assetId,
currencies = currencies,
totalFiatBalance = totalFiatBalance,
)
}.toPersistentList(),
marketChartUM = MarketChartUM.NoData(
title = resourceReference(R.string.market_chart_no_amount),
donutText = resourceReference(R.string.market_chart_bubble_no_amount),
),
onAddFundsClick = { value?.userWalletId?.let(onAddFundsClick) },
)
}
// 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
@ -95,6 +118,7 @@ internal class ForYouPortfolioReviewConverter(
return PortfolioReviewUM.Content(
tokenList = tokenList,
marketChartUM = marketChartUM,
onAddFundsClick = null,
)
}
@ -127,7 +151,7 @@ internal class ForYouPortfolioReviewConverter(
networkCount = networkGroups.size,
totalFiatBalance = totalFiatBalance,
),
tokenList = networkGroups.map(rowConverter::convertNetworkGroup).toPersistentList(),
tokenList = networkGroups.map(rowConverter::convert).toPersistentList(),
isExpanded = assetId in expandedAssetIds,
isExpandable = true,
)

View file

@ -1,5 +1,6 @@
package com.tangem.features.foryou.impl.model.converter.portfolioReview
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
@ -7,6 +8,7 @@ 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.R
import com.tangem.features.foryou.impl.components.state.AiInsightUM
import com.tangem.features.foryou.impl.components.state.DonutChartUM
import com.tangem.features.foryou.impl.components.state.DonutSegmentColor
@ -54,7 +56,10 @@ internal class ForYouPortfolioReviewMarketChartConverter(
TotalFiatBalance.Loading,
TotalFiatBalance.Failed,
null,
-> MarketChartUM.NoData
-> MarketChartUM.NoData(
title = resourceReference(R.string.market_chart_can_not_load_data),
donutText = resourceReference(R.string.market_chart_bubble_no_data),
)
}
}
}

View file

@ -20,6 +20,7 @@ import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.foryou.impl.model.converter.forYouPlaceholderBadge
import com.tangem.features.foryou.impl.model.converter.toForYouPercent
import com.tangem.utils.StringsSigns
import com.tangem.utils.converter.Converter
import com.tangem.utils.extensions.orZero
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@ -47,21 +48,20 @@ internal class ForYouPortfolioReviewTokenRowConverter(
private val userWalletId: UserWalletId?,
private val totalFiatBalance: BigDecimal,
private val onTokenClick: (UserWalletId, CryptoCurrency) -> Unit,
) {
) : Converter<List<CryptoCurrencyStatus>, TangemTokenRowUM> {
private val iconConverter = CryptoCurrencyToIconStateConverter()
/** Builds one row for all [statuses] of a single asset on the same network. */
fun convertNetworkGroup(statuses: List<CryptoCurrencyStatus>): TangemTokenRowUM {
val representative = statuses.first()
if (statuses.all { it.value is CryptoCurrencyStatus.Loading }) {
override fun convert(value: List<CryptoCurrencyStatus>): TangemTokenRowUM {
val representative = value.first()
if (value.all { it.value is CryptoCurrencyStatus.Loading }) {
return TangemTokenRowUM.Loading(id = representative.currency.id.value)
}
val currency = representative.currency
val cryptoAmount = statuses.sumOf { it.value.amount.orZero() }
val fiatAmount = statuses.sumOf { it.value.fiatAmount.orZero() }
val state = statuses.classify()
val cryptoAmount = value.sumOf { it.value.amount.orZero() }
val fiatAmount = value.sumOf { it.value.fiatAmount.orZero() }
val state = value.classify()
return TangemTokenRowUM.Content(
id = currency.id.value,
@ -78,7 +78,7 @@ internal class ForYouPortfolioReviewTokenRowConverter(
/**
* 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
* unreachable treatment as [convert], 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.
*
@ -158,7 +158,7 @@ internal class ForYouPortfolioReviewTokenRowConverter(
/** 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() }),
text = stringReference(fiatAmount.toForYouPercent(totalFiatBalance).orZero().format { percent() }),
isFlickering = state.isFlickering,
)
RowState.NoAddress -> attentionEndContent(R.string.common_no_address)

View file

@ -58,7 +58,11 @@ internal fun ForYouContent(
.drawBehind { drawRect(background.value) },
) {
promoBannersBlockComponent.ContentWithPadding(
modifier = Modifier.padding(top = 12.dp),
modifier = Modifier
.padding(top = 12.dp)
.conditional(forYouUM.notifications.isEmpty()) {
padding(bottom = 48.dp)
},
walletId = null,
horizontalItemPadding = 16.dp,
)

View file

@ -13,12 +13,13 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds.row.token.TangemTokenRowUM
import com.tangem.core.ui.ds.tabs.TangemSegmentUM
import com.tangem.core.ui.ds.tabs.TangemSegmentedPicker
import com.tangem.core.ui.ds.tabs.TangemSegmentedPickerUM
import com.tangem.core.ui.ds2.badge.TangemBadge
import com.tangem.core.ui.ds2.button.TangemButton
import com.tangem.core.ui.ds2.shimmers.TangemShimmer
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
@ -27,13 +28,10 @@ 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.ForYouPortfolioTokenList
import com.tangem.features.foryou.impl.ui.preview.ForYouPortfolioReviewPreviewData
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
@Composable
internal fun ForYouPortfolioReview(
@ -84,6 +82,18 @@ internal fun ForYouPortfolioReview(
}
ForYouPortfolioTokenList(tokenList = portfolioReviewUM.tokenList)
if (portfolioReviewUM is PortfolioReviewUM.Content && portfolioReviewUM.onAddFundsClick != null) {
TangemButton(
text = resourceReference(R.string.common_add_funds),
onClick = portfolioReviewUM.onAddFundsClick,
variant = TangemButton.Variant.Secondary,
size = TangemButton.Size.X9,
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
)
}
}
}
@ -117,23 +127,8 @@ private class ForYouPortfolioReviewPreviewProvider : PreviewParameterProvider<Po
override val values: Sequence<PortfolioReviewUM>
get() = sequenceOf(
ForYouPortfolioReviewPreviewData.reviewContent,
PortfolioReviewUM.Loading(
marketChartUM = MarketChartUM.NoData,
tokenList = buildList {
repeat(4) { index ->
add(
ForYouTokenListItemUM(
tokenRowUM = TangemTokenRowUM.Loading(
id = index.toString(),
),
tokenList = persistentListOf(),
isExpanded = false,
isExpandable = false,
),
)
}
}.toPersistentList(),
),
ForYouPortfolioReviewPreviewData.loadingState,
ForYouPortfolioReviewPreviewData.zeroPortfolioState,
)
}
// endregion

View file

@ -7,7 +7,9 @@ import com.tangem.core.ui.ds.badge.TangemBadgeType
import com.tangem.core.ui.ds.badge.TangemBadgeUM
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds.row.token.TangemTokenRowUM
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.features.foryou.impl.R
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
@ -16,6 +18,7 @@ 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 kotlinx.collections.immutable.toPersistentList
import java.math.BigDecimal
internal object ForYouPortfolioReviewPreviewData {
@ -151,5 +154,70 @@ internal object ForYouPortfolioReviewPreviewData {
isExpandable = false,
),
),
onAddFundsClick = null,
)
val loadingState = PortfolioReviewUM.Loading(
marketChartUM = MarketChartUM.NoData(
title = resourceReference(R.string.market_chart_can_not_load_data),
donutText = resourceReference(R.string.market_chart_bubble_no_data),
),
tokenList = buildList {
repeat(5) { index ->
add(
ForYouTokenListItemUM(
tokenRowUM = TangemTokenRowUM.Loading(
id = index.toString(),
),
tokenList = persistentListOf(),
isExpanded = false,
isExpandable = false,
),
)
}
}.toPersistentList(),
)
val zeroPortfolioState = PortfolioReviewUM.Content(
marketChartUM = MarketChartUM.NoData(
title = stringReference("You dont have any tokens with amount"),
donutText = stringReference("No amount on tokens"),
),
tokenList = buildList {
repeat(5) { index ->
add(
ForYouTokenListItemUM(
tokenRowUM = TangemTokenRowUM.Content(
id = "token_$index",
headIconUM = TangemIconUM.Currency(CurrencyIconState.Loading),
titleUM = TangemTokenRowUM.TitleUM.Content(
text = stringReference("Token $index"),
badge = TangemBadgeUM(
text = stringReference("Positive"),
size = TangemBadgeSize.X4,
type = TangemBadgeType.Tinted,
color = TangemBadgeColor.Green,
),
),
subtitleUM = TangemTokenRowUM.SubtitleUM.Content(
text = stringReference("Some network"),
),
topEndContentUM = TangemTokenRowUM.EndContentUM.Content(
text = stringReference("\$0"),
),
bottomEndContentUM = TangemTokenRowUM.EndContentUM.Content(
text = stringReference("0.00%"),
),
onItemClick = {},
onItemLongClick = { _, _ -> },
),
tokenList = persistentListOf(),
isExpanded = false,
isExpandable = false,
),
)
}
}.toPersistentList(),
onAddFundsClick = { },
)
}

View file

@ -108,7 +108,7 @@ internal class ForYouModelTest {
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)
assertThat(loading.marketChartUM).isInstanceOf(MarketChartUM.NoData::class.java)
}
@Test

View file

@ -341,7 +341,7 @@ internal class ForYouPortfolioReviewConverterTest {
val result = createConverter().convert(statusList) as PortfolioReviewUM.Content
// Assert
assertThat(result.marketChartUM).isEqualTo(MarketChartUM.NoData)
assertThat(result.marketChartUM).isInstanceOf(MarketChartUM.NoData::class.java)
}
@Test
@ -350,7 +350,155 @@ internal class ForYouPortfolioReviewConverterTest {
val result = createConverter().convert(null) as PortfolioReviewUM.Content
// Assert
assertThat(result.marketChartUM).isEqualTo(MarketChartUM.NoData)
assertThat(result.marketChartUM).isInstanceOf(MarketChartUM.NoData::class.java)
}
}
@Nested
inner class ZeroBalancePortfolio {
@Test
fun `GIVEN all currencies have zero fiat WHEN convert THEN market chart is the no-amount NoData`() {
// Arrange
val statuses = listOf(
createStatus(
createCoin(rawCurrencyId = "btc", symbol = "BTC", networkId = "bitcoin"),
loadedValue(BigDecimal.ZERO, BigDecimal.ZERO),
),
createStatus(
createCoin(rawCurrencyId = "eth", symbol = "ETH", networkId = "ethereum"),
loadedValue(BigDecimal.ZERO, BigDecimal.ZERO),
),
)
// Act
val result = convert(statuses, totalFiatBalance = BigDecimal.ZERO)
// Assert — the zero-balance treatment, not the generic can-not-load-data chart
assertThat(result.marketChartUM).isEqualTo(
MarketChartUM.NoData(
title = resourceReference(R.string.market_chart_no_amount),
donutText = resourceReference(R.string.market_chart_bubble_no_amount),
),
)
}
@Test
fun `GIVEN all currencies have zero fiat WHEN add funds clicked THEN callback receives the wallet id`() {
// Arrange
val statuses = listOf(
createStatus(
createCoin(rawCurrencyId = "btc", symbol = "BTC", networkId = "bitcoin"),
loadedValue(BigDecimal.ZERO, BigDecimal.ZERO),
),
)
var addFundsWalletId: UserWalletId? = null
val converter = createConverter(onAddFundsClick = { addFundsWalletId = it })
// Act
val result = converter.convert(
accountStatusList(statuses, BigDecimal.ZERO),
) as PortfolioReviewUM.Content
result.onAddFundsClick?.invoke()
// Assert
assertThat(result.onAddFundsClick).isNotNull()
assertThat(addFundsWalletId).isEqualTo(UserWalletId("01"))
}
@Test
fun `GIVEN a non-zero balance WHEN convert THEN add funds action is absent`() {
// Arrange
val statuses = listOf(
createStatus(
createCoin(rawCurrencyId = "btc", symbol = "BTC", networkId = "bitcoin"),
loadedValue(BigDecimal.ONE, BigDecimal("100")),
),
)
// Act
val result = convert(statuses, totalFiatBalance = BigDecimal("100"))
// Assert
assertThat(result.onAddFundsClick).isNull()
}
@Test
fun `GIVEN null account status list WHEN add funds clicked THEN callback is not invoked`() {
// Arrange — without a wallet there is nowhere to add funds, so the click must be a no-op
var clicked = false
val converter = createConverter(onAddFundsClick = { clicked = true })
// Act
val result = converter.convert(null) as PortfolioReviewUM.Content
result.onAddFundsClick?.invoke()
// Assert
assertThat(clicked).isFalse()
}
@Test
fun `GIVEN more than five zero-fiat currencies WHEN convert THEN list is capped with no Other row`() {
// Arrange — 7 distinct zero-balance assets; the zero-balance branch shows the first 5
// as-is instead of ranking and collapsing the excess into an "Other" row
val statuses = (1..7).map { index ->
createStatus(
createCoin(rawCurrencyId = "asset-$index", symbol = "A$index", networkId = "net-$index"),
loadedValue(BigDecimal.ZERO, BigDecimal.ZERO),
)
}
// Act
val result = convert(statuses, totalFiatBalance = BigDecimal.ZERO)
// Assert
assertThat(result.tokenList.map { it.tokenRowUM.id })
.containsExactly("asset-1", "asset-2", "asset-3", "asset-4", "asset-5")
.inOrder()
}
@Test
fun `GIVEN zero-fiat asset on several networks WHEN convert THEN grouped into one expandable item`() {
// Arrange — the same asset (shared rawCurrencyId) with zero balances on two networks
val onEth = createToken(rawCurrencyId = "usdc", symbol = "USDC", networkId = "ethereum")
val onSol = createToken(rawCurrencyId = "usdc", symbol = "USDC", networkId = "solana")
val statuses = listOf(
createStatus(onEth, loadedValue(BigDecimal.ZERO, BigDecimal.ZERO)),
createStatus(onSol, loadedValue(BigDecimal.ZERO, BigDecimal.ZERO)),
)
// Act
val result = convert(statuses, totalFiatBalance = BigDecimal.ZERO)
// Assert
val item = result.tokenList.single()
assertThat(item.tokenRowUM.id).isEqualTo("usdc")
assertThat(item.tokenList).hasSize(2)
assertThat(item.isExpandable).isTrue()
}
@Test
fun `GIVEN zero and null fiat currencies mixed WHEN convert THEN zero-balance treatment is not applied`() {
// Arrange — an unreachable holding has an *unknown* balance, not a resolved zero, so the
// portfolio must not collapse into the add-funds empty state
val statuses = listOf(
createStatus(
createCoin(rawCurrencyId = "eth", symbol = "ETH", networkId = "ethereum"),
loadedValue(BigDecimal.ZERO, BigDecimal.ZERO),
),
createStatus(
createCoin(rawCurrencyId = "btc", symbol = "BTC", networkId = "bitcoin"),
unreachableValue(),
),
)
// Act
val result = convert(statuses, totalFiatBalance = BigDecimal.ZERO)
// Assert — falls through to the ranked branch: no add-funds action, the resolved zero is
// dropped, the unknown-balance holding stays visible
assertThat(result.onAddFundsClick).isNull()
assertThat(result.tokenList.map { it.tokenRowUM.id }).containsExactly("btc")
}
}
@ -364,11 +512,13 @@ internal class ForYouPortfolioReviewConverterTest {
expandedAssetIds: Set<String> = emptySet(),
expandClick: (String) -> Unit = {},
onTokenClick: (UserWalletId, CryptoCurrency) -> Unit = { _, _ -> },
onAddFundsClick: (UserWalletId) -> Unit = {},
): ForYouPortfolioReviewConverter = ForYouPortfolioReviewConverter(
appCurrency = appCurrency,
expandedAssetIds = expandedAssetIds,
expandClick = expandClick,
onTokenClick = onTokenClick,
onAddFundsClick = onAddFundsClick,
)
private fun accountStatusList(

View file

@ -25,24 +25,24 @@ internal class ForYouPortfolioReviewTokenRowConverterTest {
private val appCurrency: AppCurrency = AppCurrency.Default
@Nested
inner class ConvertNetworkGroup {
inner class Convert {
@Test
fun `GIVEN all statuses Loading WHEN convertNetworkGroup THEN row is Loading with representative id`() {
fun `GIVEN all statuses Loading WHEN convert THEN row is Loading with representative id`() {
// Arrange
val currency = createCurrency(id = "coin-eth", symbol = "ETH")
val statuses = listOf(createStatus(currency, CryptoCurrencyStatus.Loading))
val converter = createConverter(totalFiatBalance = BigDecimal("1000"))
// Act
val result = converter.convertNetworkGroup(statuses)
val result = converter.convert(statuses)
// Assert
assertThat(result).isEqualTo(TangemTokenRowUM.Loading(id = "coin-eth"))
}
@Test
fun `GIVEN single loaded status WHEN convertNetworkGroup THEN row is Content with its amounts`() {
fun `GIVEN single loaded status WHEN convert THEN row is Content with its amounts`() {
// Arrange
val currency = createCurrency(id = "coin-eth", symbol = "ETH", networkName = "Ethereum")
val statuses = listOf(
@ -51,7 +51,7 @@ internal class ForYouPortfolioReviewTokenRowConverterTest {
val converter = createConverter(totalFiatBalance = BigDecimal("1000"))
// Act
val result = converter.convertNetworkGroup(statuses) as TangemTokenRowUM.Content
val result = converter.convert(statuses) as TangemTokenRowUM.Content
// Assert
assertThat(result.id).isEqualTo("coin-eth")
@ -62,7 +62,7 @@ internal class ForYouPortfolioReviewTokenRowConverterTest {
}
@Test
fun `GIVEN several statuses of the same asset on one network WHEN convertNetworkGroup THEN amounts are summed`() {
fun `GIVEN several statuses of the same asset on one network WHEN convert THEN amounts are summed`() {
// Arrange — same asset held in two accounts on the same network aggregates into one row
val currency = createCurrency(id = "coin-eth", symbol = "ETH", networkName = "Ethereum")
val statuses = listOf(
@ -72,7 +72,7 @@ internal class ForYouPortfolioReviewTokenRowConverterTest {
val converter = createConverter(totalFiatBalance = BigDecimal("1000"))
// Act
val result = converter.convertNetworkGroup(statuses) as TangemTokenRowUM.Content
val result = converter.convert(statuses) as TangemTokenRowUM.Content
// Assert
val topEnd = result.topEndContentUM as TangemTokenRowUM.EndContentUM.Content
@ -80,7 +80,7 @@ internal class ForYouPortfolioReviewTokenRowConverterTest {
}
@Test
fun `GIVEN mixed Loading and Loaded statuses WHEN convertNetworkGroup THEN row is Content`() {
fun `GIVEN mixed Loading and Loaded statuses WHEN convert THEN row is Content`() {
// Arrange — not *all* statuses are Loading, so it should not collapse to a Loading row
val currency = createCurrency(id = "coin-eth", symbol = "ETH")
val statuses = listOf(
@ -90,14 +90,14 @@ internal class ForYouPortfolioReviewTokenRowConverterTest {
val converter = createConverter(totalFiatBalance = BigDecimal("1000"))
// Act
val result = converter.convertNetworkGroup(statuses)
val result = converter.convert(statuses)
// Assert
assertThat(result).isInstanceOf(TangemTokenRowUM.Content::class.java)
}
@Test
fun `GIVEN loaded status from cache WHEN convertNetworkGroup THEN content flickers`() {
fun `GIVEN loaded status from cache WHEN convert THEN content flickers`() {
// Arrange
val currency = createCurrency(id = "coin-eth", symbol = "ETH", networkName = "Ethereum")
val statuses = listOf(
@ -109,7 +109,7 @@ internal class ForYouPortfolioReviewTokenRowConverterTest {
val converter = createConverter(totalFiatBalance = BigDecimal("1000"))
// Act
val result = converter.convertNetworkGroup(statuses) as TangemTokenRowUM.Content
val result = converter.convert(statuses) as TangemTokenRowUM.Content
// Assert
val topEnd = result.topEndContentUM as TangemTokenRowUM.EndContentUM.Content
@ -120,7 +120,7 @@ internal class ForYouPortfolioReviewTokenRowConverterTest {
}
@Test
fun `GIVEN loaded status only-cache WHEN convertNetworkGroup THEN error-sync start icon shown`() {
fun `GIVEN loaded status only-cache WHEN convert THEN error-sync start icon shown`() {
// Arrange
val currency = createCurrency(id = "coin-eth", symbol = "ETH", networkName = "Ethereum")
val statuses = listOf(
@ -136,7 +136,7 @@ internal class ForYouPortfolioReviewTokenRowConverterTest {
val converter = createConverter(totalFiatBalance = BigDecimal("1000"))
// Act
val result = converter.convertNetworkGroup(statuses) as TangemTokenRowUM.Content
val result = converter.convert(statuses) as TangemTokenRowUM.Content
// Assert
val topEnd = result.topEndContentUM as TangemTokenRowUM.EndContentUM.Content
@ -145,14 +145,14 @@ internal class ForYouPortfolioReviewTokenRowConverterTest {
}
@Test
fun `GIVEN missed derivation status WHEN convertNetworkGroup THEN no-address treatment`() {
fun `GIVEN missed derivation status WHEN convert 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
val result = converter.convert(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
@ -162,14 +162,14 @@ internal class ForYouPortfolioReviewTokenRowConverterTest {
}
@Test
fun `GIVEN unreachable status WHEN convertNetworkGroup THEN dash on top and attention icon on bottom`() {
fun `GIVEN unreachable status WHEN convert 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
val result = converter.convert(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
@ -179,7 +179,7 @@ internal class ForYouPortfolioReviewTokenRowConverterTest {
}
@Test
fun `GIVEN mixed Loaded and Unreachable WHEN convertNetworkGroup THEN collapses to unreachable`() {
fun `GIVEN mixed Loaded and Unreachable WHEN convert 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(
@ -189,7 +189,7 @@ internal class ForYouPortfolioReviewTokenRowConverterTest {
val converter = createConverter(totalFiatBalance = BigDecimal("1000"))
// Act
val result = converter.convertNetworkGroup(statuses) as TangemTokenRowUM.Content
val result = converter.convert(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
@ -212,7 +212,7 @@ internal class ForYouPortfolioReviewTokenRowConverterTest {
)
// Act
val result = converter.convertNetworkGroup(statuses) as TangemTokenRowUM.Content
val result = converter.convert(statuses) as TangemTokenRowUM.Content
result.onItemClick?.invoke()
// Assert
@ -234,7 +234,7 @@ internal class ForYouPortfolioReviewTokenRowConverterTest {
)
// Act
val result = converter.convertNetworkGroup(statuses) as TangemTokenRowUM.Content
val result = converter.convert(statuses) as TangemTokenRowUM.Content
result.onItemClick?.invoke()
// Assert
@ -242,7 +242,7 @@ internal class ForYouPortfolioReviewTokenRowConverterTest {
}
@Test
fun `GIVEN mixed MissedDerivation and Unreachable WHEN convertNetworkGroup THEN missed-derivation wins`() {
fun `GIVEN mixed MissedDerivation and Unreachable WHEN convert 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(
@ -252,7 +252,7 @@ internal class ForYouPortfolioReviewTokenRowConverterTest {
val converter = createConverter(totalFiatBalance = BigDecimal("1000"))
// Act
val result = converter.convertNetworkGroup(statuses) as TangemTokenRowUM.Content
val result = converter.convert(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

View file

@ -137,7 +137,8 @@ internal class SetPortfolioReviewTransformerTest {
private fun contentPortfolioReview(): PortfolioReviewUM.Content = PortfolioReviewUM.Content(
tokenList = persistentListOf(),
marketChartUM = MarketChartUM.NoData,
marketChartUM = noDataChart(),
onAddFundsClick = null,
)
private fun contentEarnOpportunities(): EarnOpportunitiesUM.Content = EarnOpportunitiesUM.Content(
@ -148,6 +149,11 @@ internal class SetPortfolioReviewTransformerTest {
onAllEarnTokensClick = {},
)
private fun noDataChart(): MarketChartUM.NoData = MarketChartUM.NoData(
title = stringReference("No data"),
donutText = stringReference("No data"),
)
private fun accountStatusList(totalFiatBalance: TotalFiatBalance): AccountStatusList = mockk {
every { this@mockk.totalFiatBalance } returns totalFiatBalance
}
@ -158,7 +164,7 @@ internal class SetPortfolioReviewTransformerTest {
private fun loadingState(): ForYouUM = ForYouUM(
portfolioReviewUM = PortfolioReviewUM.Loading(
tokenList = persistentListOf(),
marketChartUM = MarketChartUM.NoData,
marketChartUM = noDataChart(),
),
earnOpportunities = EarnOpportunitiesUM.Loading(tokenList = persistentListOf()),
notifications = persistentListOf(),