Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-02 16:26:26 +05:00
commit 9c0aec7e8d
20 changed files with 2671 additions and 6 deletions

View file

@ -20,20 +20,35 @@ dependencies {
/** Features */ /** Features */
implementation(projects.features.forYou.api) implementation(projects.features.forYou.api)
implementation(projects.features.promoBanners.api)
implementation(projects.features.commonFeatures.api)
/** Domain */
implementation(projects.domain.common)
implementation(projects.domain.models)
implementation(projects.domain.account.status)
implementation(projects.domain.appCurrency)
/** Core */ /** Core */
implementation(projects.core.decompose) implementation(projects.core.decompose)
implementation(projects.core.ui) implementation(projects.core.ui)
implementation(projects.core.configToggles) implementation(projects.core.configToggles)
implementation(projects.common.ui)
implementation(deps.compose.ui) implementation(deps.compose.ui)
implementation(deps.compose.ui.tooling)
implementation(deps.compose.foundation) implementation(deps.compose.foundation)
implementation(deps.compose.animation) implementation(deps.compose.animation)
implementation(deps.lifecycle.compose) implementation(deps.lifecycle.compose)
implementation(deps.compose.material3) implementation(deps.compose.material3)
implementation(deps.compose.ui.tooling) implementation(deps.kotlin.immutable.collections)
/** DI */ /** DI */
implementation(deps.hilt.android) implementation(deps.hilt.android)
kapt(deps.hilt.kapt) kapt(deps.hilt.kapt)
/** Test */
testImplementation(projects.common.test)
testImplementation(projects.test.core)
} }

View file

@ -5,15 +5,16 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.State import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.child
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.R import com.tangem.core.ui.R
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
import com.tangem.core.ui.components.haze.hazeEffectTangem import com.tangem.core.ui.components.haze.hazeEffectTangem
@ -22,7 +23,12 @@ import com.tangem.core.ui.ds.topbar.TangemTopBarType
import com.tangem.core.ui.extensions.clickableSingle import com.tangem.core.ui.extensions.clickableSingle
import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemTheme 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_chevron_left_20
import com.tangem.features.foryou.ForYouComponent import com.tangem.features.foryou.ForYouComponent
import com.tangem.features.foryou.impl.model.ForYouModel
import com.tangem.features.foryou.impl.ui.ForYouContent
import com.tangem.features.promobanners.api.PromoBannersBlockComponent
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
@ -30,8 +36,21 @@ import dagger.assisted.AssistedInject
internal class DefaultForYouComponent @AssistedInject constructor( internal class DefaultForYouComponent @AssistedInject constructor(
@Assisted context: AppComponentContext, @Assisted context: AppComponentContext,
@Suppress("UnusedPrivateMember") @Assisted params: Unit, @Suppress("UnusedPrivateMember") @Assisted params: Unit,
private val promoBannersBlockComponentFactory: PromoBannersBlockComponent.Factory,
) : AppComponentContext by context, ForYouComponent { ) : AppComponentContext by context, ForYouComponent {
private val model: ForYouModel = getOrCreateModel()
private val promoBannersBlockComponent: PromoBannersBlockComponent by lazy {
promoBannersBlockComponentFactory.create(
context = child("promoBannersBlockComponent"),
params = PromoBannersBlockComponent.Params(
placeholder = PromoBannersBlockComponent.Placeholder.FEED,
isInitiallyVisibleOnScreen = false,
),
)
}
@Composable @Composable
override fun Title(bottomSheetState: State<BottomSheetState>) { override fun Title(bottomSheetState: State<BottomSheetState>) {
TangemTopBar( TangemTopBar(
@ -39,7 +58,7 @@ internal class DefaultForYouComponent @AssistedInject constructor(
type = TangemTopBarType.BottomSheet, type = TangemTopBarType.BottomSheet,
startContent = { startContent = {
Icon( Icon(
imageVector = ImageVector.vectorResource(id = R.drawable.ic_arrow_back_28), imageVector = Icons.ic_chevron_left_20,
contentDescription = null, contentDescription = null,
tint = TangemTheme.colors3.icon.primary, tint = TangemTheme.colors3.icon.primary,
modifier = Modifier modifier = Modifier
@ -62,7 +81,15 @@ internal class DefaultForYouComponent @AssistedInject constructor(
contentPadding: PaddingValues, contentPadding: PaddingValues,
modifier: Modifier, modifier: Modifier,
) { ) {
Text("FOR YOU") val uiState by model.uiState.collectAsStateWithLifecycle()
ForYouContent(
forYouUM = uiState,
bottomSheetState = bottomSheetState,
promoBannersBlockComponent = promoBannersBlockComponent,
contentPadding = contentPadding,
modifier = modifier,
)
} }
@AssistedFactory @AssistedFactory

View file

@ -1,15 +1,19 @@
package com.tangem.features.foryou.impl.di package com.tangem.features.foryou.impl.di
import com.tangem.core.configtoggle.feature.FeatureTogglesManager import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.core.decompose.model.Model
import com.tangem.features.foryou.ForYouComponent import com.tangem.features.foryou.ForYouComponent
import com.tangem.features.foryou.ForYouFeatureToggles import com.tangem.features.foryou.ForYouFeatureToggles
import com.tangem.features.foryou.impl.DefaultForYouComponent import com.tangem.features.foryou.impl.DefaultForYouComponent
import com.tangem.features.foryou.impl.featuretoggles.DefaultForYouFeatureToggles import com.tangem.features.foryou.impl.featuretoggles.DefaultForYouFeatureToggles
import com.tangem.features.foryou.impl.model.ForYouModel
import dagger.Binds import dagger.Binds
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
import javax.inject.Singleton import javax.inject.Singleton
@Module @Module
@ -30,4 +34,9 @@ internal interface ForYouComponentModule {
@Binds @Binds
@Singleton @Singleton
fun bindForYouComponent(factory: DefaultForYouComponent.Factory): ForYouComponent.Factory fun bindForYouComponent(factory: DefaultForYouComponent.Factory): ForYouComponent.Factory
@Binds
@IntoMap
@ClassKey(ForYouModel::class)
fun bindForYouModel(impl: ForYouModel): Model
} }

View file

@ -0,0 +1,39 @@
package com.tangem.features.foryou.impl.entity
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 kotlinx.collections.immutable.ImmutableList
internal data class ForYouUM(
val walletListUM: WalletListUM,
val portfolioReviewUM: PortfolioReviewUM,
)
@Immutable
internal sealed interface PortfolioReviewUM {
val tokenList: ImmutableList<ForYouTokenListItemUM>
data class Loading(
override val tokenList: ImmutableList<ForYouTokenListItemUM>,
) : PortfolioReviewUM
data class Content(
override val tokenList: ImmutableList<ForYouTokenListItemUM>,
val periodPickerUM: TangemSegmentedPickerUM,
val assetCount: TextReference,
val topHoldingPercent: TextReference,
val onPeriodClick: (TangemSegmentUM) -> Unit,
) : PortfolioReviewUM
}
@Immutable
internal data class ForYouTokenListItemUM(
val tokenRowUM: TangemTokenRowUM,
val tokenList: ImmutableList<TangemTokenRowUM>,
val isExpanded: Boolean,
val isExpandable: Boolean,
)

View file

@ -0,0 +1,143 @@
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.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
import kotlinx.coroutines.flow.*
import javax.inject.Inject
@Stable
@ModelScoped
internal class ForYouModel @Inject constructor(
userWalletsListRepository: UserWalletsListRepository,
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()),
portfolioReviewUM = PortfolioReviewUM.Loading(
tokenList = buildList<ForYouTokenListItemUM> {
repeat(4) { index ->
add(
ForYouTokenListItemUM(
tokenRowUM = TangemTokenRowUM.Loading(
id = index.toString(),
),
tokenList = persistentListOf(),
isExpanded = false,
isExpandable = false,
),
)
}
}.toPersistentList(),
),
),
)
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()
uiState.update(
SetPortfolioReviewTransformer(
walletListUM = WalletListUM(
items = if (tabs.size != 1) tabs.toPersistentList() else persistentListOf(),
),
currencies = currencies,
totalFiatBalance = totalFiatBalance,
appCurrency = selectedAppCurrencyFlow.value,
expandedAssetIds = expanded,
expandClick = ::onExpandClick,
onPeriodClick = ::onPeriodClick,
),
)
}
.flowOn(dispatchers.default)
.launchIn(modelScope)
}
private fun createSelectedAppCurrencyFlow(): StateFlow<AppCurrency> {
return getSelectedAppCurrencyUseCase().map { maybeAppCurrency ->
maybeAppCurrency.getOrElse { AppCurrency.Default }
}.stateIn(
scope = modelScope,
started = SharingStarted.Eagerly,
initialValue = AppCurrency.Default,
)
}
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
}
}
private fun onPeriodClick(tangemSegmentUM: TangemSegmentUM) {
uiState.update { state ->
state.copy(
portfolioReviewUM = (state.portfolioReviewUM as? PortfolioReviewUM.Content)?.copy(
periodPickerUM = state.portfolioReviewUM.periodPickerUM.copy(
initialSelectedItem = tangemSegmentUM,
),
) ?: state.portfolioReviewUM,
)
}
}
}

View file

@ -0,0 +1,52 @@
package com.tangem.features.foryou.impl.model.converter
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
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.
*/
/**
* Cross-network grouping key for the portfolio review: the same asset on different networks (e.g. USDC
* on Solana and Ethereum) shares its `rawCurrencyId`, so they group under a single item. Custom tokens
* have no raw id and fall back to their unique currency id, staying in their own group.
*/
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).
*/
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() })
}
// TODO For You: replace this placeholder with the real price-change badge once the design is wired.
internal fun forYouPlaceholderBadge(): TangemBadgeUM = TangemBadgeUM(
text = stringReference("Positive"),
size = TangemBadgeSize.X4,
type = TangemBadgeType.Tinted,
color = TangemBadgeColor.Green,
)

View file

@ -0,0 +1,148 @@
package com.tangem.features.foryou.impl.model.converter
import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
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.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.features.foryou.impl.R
import com.tangem.features.foryou.impl.entity.ForYouTokenListItemUM
import com.tangem.utils.converter.Converter
import com.tangem.utils.extensions.orZero
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import java.math.BigDecimal
/**
* Builds the For You portfolio-review list: groups the given currency statuses by asset across networks
* (see [forYouGroupKey]) and maps each group to a [ForYouTokenListItemUM] an aggregate asset row plus,
* when the asset spans more than one network, its per-network child rows.
*
* The child rows are grouped by network (delegated to [ForYouTokenRowConverter]) so a network appears
* once per asset even if the asset is held on it in several accounts;
*
* Modelled on `TokenListStateConverter` (a list converter delegating to a per-item converter).
*/
@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,
) : Converter<List<CryptoCurrencyStatus>, ImmutableList<ForYouTokenListItemUM>> {
private val iconConverter = CryptoCurrencyToIconStateConverter()
private val rowConverter = ForYouTokenRowConverter(appCurrency = appCurrency, totalFiatBalance = totalFiatBalance)
override fun convert(value: List<CryptoCurrencyStatus>): ImmutableList<ForYouTokenListItemUM> {
val assetItems = value
.groupBy { it.forYouGroupKey() }
.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) {
assetItems + createOtherItem()
} else {
assetItems
}.toPersistentList()
}
private fun createListItem(assetId: String, currencies: List<CryptoCurrencyStatus>): ForYouTokenListItemUM {
// Group the asset's holdings by blockchain (network.id.rawId, derivation-independent) so each
// network appears once even when the asset is held across several accounts/derivations on it,
// summing those balances. Order by balance so the expanded breakdown reads top-down.
val networkGroups = currencies
.groupBy { it.currency.network.id.rawId }
.values
.sortedByDescending { group -> group.sumOf { it.value.fiatAmount.orZero() } }
return ForYouTokenListItemUM(
tokenRowUM = createAssetRow(
assetId = assetId,
currencies = currencies,
networkCount = networkGroups.size,
),
tokenList = networkGroups.map(rowConverter::convertNetworkGroup).toPersistentList(),
isExpanded = assetId in expandedAssetIds,
isExpandable = true,
)
}
private fun createAssetRow(
assetId: String,
currencies: List<CryptoCurrencyStatus>,
networkCount: Int,
): TangemTokenRowUM {
if (currencies.all { it.value is CryptoCurrencyStatus.Loading }) {
return TangemTokenRowUM.Loading(id = assetId)
}
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
}
}
return TangemTokenRowUM.Content(
id = assetId,
headIconUM = TangemIconUM.Currency(iconConverter.convert(asset)),
titleUM = TangemTokenRowUM.TitleUM.Content(
text = stringReference(asset.currency.symbol),
badge = forYouPlaceholderBadge(),
),
subtitleUM = TangemTokenRowUM.SubtitleUM.Content(
text = subtitle,
),
topEndContentUM = TangemTokenRowUM.EndContentUM.Content(
text = assetFiatBalance.toForYouFiatText(appCurrency),
),
bottomEndContentUM = TangemTokenRowUM.EndContentUM.Content(
text = assetFiatBalance.toForYouPercentText(totalFiatBalance),
),
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"),
),
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,
)
private companion object {
const val OTHER_ROW_ID = "for_you_other_assets"
}
}

View file

@ -0,0 +1,61 @@
package com.tangem.features.foryou.impl.model.converter
import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
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.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.utils.StringsSigns
import com.tangem.utils.extensions.orZero
import java.math.BigDecimal
/**
* Builds a single per-network child row of an asset for the For You portfolio review.
*
* 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.
*/
internal class ForYouTokenRowConverter(
private val appCurrency: AppCurrency,
private val totalFiatBalance: BigDecimal,
) {
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 }) {
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() }
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),
),
onItemClick = null,
onItemLongClick = null,
)
}
}

View file

@ -0,0 +1,110 @@
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.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.features.commonfeatures.api.choosetoken.model.WalletListUM
import com.tangem.features.foryou.impl.entity.ForYouUM
import com.tangem.features.foryou.impl.entity.PortfolioReviewUM
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).
*
* 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.
*
* 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 appCurrency: AppCurrency,
private val expandedAssetIds: Set<String>,
private val expandClick: (assetId: String) -> Unit,
private val onPeriodClick: (TangemSegmentUM) -> Unit,
) : 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 rankedAssets = currencies
.filterNot { it.value.fiatAmount.orZero().isZero() }
.groupBy { it.forYouGroupKey() }
.map { (_, networks) -> networks to networks.sumOf { it.value.fiatAmount.orZero() } }
.sortedByDescending { (_, assetBalance) -> assetBalance }
// The top assets are shown individually (each flattened back to its networks so the converter can
// regroup them by network); the remaining assets are collapsed into a single "Other" row.
val topAssets = rankedAssets.take(TOP_HOLDINGS_COUNT)
val otherAssets = rankedAssets.drop(TOP_HOLDINGS_COUNT)
val topCurrencies = topAssets.flatMap { (networks, _) -> networks }
val topBalance = topAssets.sumOf { (_, assetBalance) -> assetBalance }
val tokenList = ForYouTokenListConverter(
appCurrency = appCurrency,
totalFiatBalance = totalFiatBalance,
expandedAssetIds = expandedAssetIds,
expandClick = expandClick,
otherAssetCount = otherAssets.size,
otherFiatBalance = otherAssets.sumOf { (_, assetBalance) -> assetBalance },
).convert(topCurrencies)
return prevState.copy(
walletListUM = walletListUM,
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,
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"))
return TangemSegmentedPickerUM(
items = persistentListOf(
day,
TangemSegmentUM(id = "1", title = stringReference("Week")),
TangemSegmentUM(id = "2", title = stringReference("Month")),
),
initialSelectedItem = day,
isFixed = true,
isAltSurface = true,
)
}
private companion object {
const val TOP_HOLDINGS_COUNT = 4
}
}

View file

@ -0,0 +1,116 @@
package com.tangem.features.foryou.impl.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
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 androidx.compose.ui.unit.dp
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.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.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
import kotlinx.coroutines.flow.distinctUntilChanged
@Composable
internal fun ForYouContent(
forYouUM: ForYouUM,
bottomSheetState: State<BottomSheetState>,
promoBannersBlockComponent: PromoBannersBlockComponent,
contentPadding: PaddingValues,
modifier: Modifier = Modifier,
) {
LaunchedEffect(bottomSheetState, promoBannersBlockComponent) {
snapshotFlow { bottomSheetState.value == BottomSheetState.EXPANDED }
.distinctUntilChanged()
.collect(promoBannersBlockComponent::setVisibleOnScreen)
}
val background = LocalMainBottomSheetColor.current
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(top = contentPadding.calculateTopPadding())
.drawBehind { drawRect(background.value) },
) {
WalletTabsBlock(walletList = forYouUM.walletListUM)
SpacerH(12.dp)
promoBannersBlockComponent.ContentWithPadding(
modifier = Modifier,
horizontalItemPadding = 16.dp,
)
ForYouPortfolioReview(
portfolioReviewUM = forYouUM.portfolioReviewUM,
modifier = Modifier.padding(horizontal = 16.dp),
)
SpacerH(48.dp)
}
}
// region Preview
@Composable
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun ForYouContent_Preview(@PreviewParameter(ForYouContentPreviewProvider::class) params: ForYouUM) {
TangemThemePreviewRedesign {
ForYouContent(
forYouUM = params,
bottomSheetState = remember { mutableStateOf(BottomSheetState.EXPANDED) },
promoBannersBlockComponent = object : PromoBannersBlockComponent {
@Composable
override fun ContentWithPadding(horizontalItemPadding: Dp, modifier: Modifier) {
}
override fun setVisibleOnScreen(isVisible: Boolean) {}
},
contentPadding = PaddingValues.Zero,
modifier = Modifier.background(TangemTheme.colors3.bg.primary),
)
}
}
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,
),
),
),
portfolioReviewUM = ForYouPortfolioReviewPreviewData.reviewContent,
),
)
}
// endregion

View file

@ -0,0 +1,112 @@
package com.tangem.features.foryou.impl.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.extensions.stringReference
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.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
import kotlinx.collections.immutable.toPersistentList
@Composable
internal fun ForYouPortfolioReview(portfolioReviewUM: PortfolioReviewUM, modifier: Modifier = Modifier) {
Column(modifier = modifier) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "Portfolio Review", // TODO For You
style = TangemTheme.typography3.heading.small,
color = TangemTheme.colors3.text.primary,
)
TangemBadge(
text = stringReference("All accounts"), // TODO For You
variant = TangemBadge.Variant.Solid,
size = TangemBadge.Size.X9,
iconEnd = TangemIconUM.Icon(Icons.ic_chevron_down_16),
)
}
SpacerH(16.dp)
ForYouMarketChartContent(portfolioReviewUM)
SpacerH(8.dp)
when (portfolioReviewUM) {
is PortfolioReviewUM.Content -> {
TangemSegmentedPicker(
tangemSegmentedPickerUM = portfolioReviewUM.periodPickerUM,
onClick = portfolioReviewUM.onPeriodClick,
)
}
is PortfolioReviewUM.Loading -> RectangleShimmer(
modifier = Modifier
.fillMaxWidth()
.height(40.dp),
radius = 100.dp,
)
}
ForYouPortfolioTokenList(tokenList = portfolioReviewUM.tokenList)
}
}
// region Preview
@Composable
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun ForYouPortfolioReview_Review(
@PreviewParameter(ForYouPortfolioReviewPreviewProvider::class) params: PortfolioReviewUM,
) {
TangemThemePreviewRedesign {
ForYouPortfolioReview(
portfolioReviewUM = params,
modifier = Modifier.background(TangemTheme.colors3.bg.primary),
)
}
}
private class ForYouPortfolioReviewPreviewProvider : PreviewParameterProvider<PortfolioReviewUM> {
override val values: Sequence<PortfolioReviewUM>
get() = sequenceOf(
ForYouPortfolioReviewPreviewData.reviewContent,
PortfolioReviewUM.Loading(
tokenList = buildList {
repeat(4) { index ->
add(
ForYouTokenListItemUM(
tokenRowUM = TangemTokenRowUM.Loading(
id = index.toString(),
),
tokenList = persistentListOf(),
isExpanded = false,
isExpandable = false,
),
)
}
}.toPersistentList(),
),
)
}
// endregion

View file

@ -0,0 +1,68 @@
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

@ -0,0 +1,365 @@
package com.tangem.features.foryou.impl.ui.components
import androidx.compose.animation.*
import androidx.compose.animation.SharedTransitionScope.ResizeMode.Companion.scaleToBounds
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.animateIntAsState
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInWindow
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.lerp
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEachIndexed
import com.tangem.common.ui.tokens.SlideInItemVisibility
import com.tangem.core.ui.components.SpacerWMax
import com.tangem.core.ui.components.TextShimmer
import com.tangem.core.ui.components.account.AccountIconSize
import com.tangem.core.ui.components.account.toBoxSize
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.currency.icon.TangemCurrencyIcon
import com.tangem.core.ui.decorations.roundedShapeItemDecoration
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds.row.token.TangemTokenRow
import com.tangem.core.ui.ds.row.token.TangemTokenRowUM
import com.tangem.core.ui.ds.row.token.internal.TokenRowTitle
import com.tangem.core.ui.ds2.row.TangemRow
import com.tangem.core.ui.ds2.row.TangemRowVerticalAlignment
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.utils.ProvideSharedTransitionScope
import com.tangem.core.ui.utils.lazyListItemPosition
import com.tangem.core.ui.utils.sharedBoundsSafely
import com.tangem.features.foryou.impl.entity.ForYouTokenListItemUM
import com.tangem.utils.StringsSigns
import kotlinx.collections.immutable.ImmutableList
@Composable
internal fun ForYouPortfolioTokenList(tokenList: ImmutableList<ForYouTokenListItemUM>, modifier: Modifier = Modifier) {
Column(modifier = modifier) {
val outerLastIndex = tokenList.lastIndex
tokenList.fastForEachIndexed { index, listItem ->
key(listItem.tokenRowUM.id) {
PortfolioTokenItem(listItem = listItem, index = index, outerLastIndex = outerLastIndex)
}
}
}
}
@Composable
private fun PortfolioTokenItem(listItem: ForYouTokenListItemUM, index: Int, outerLastIndex: Int) {
PortfolioAssetItem(listItem = listItem, index = index, outerLastIndex = outerLastIndex)
val lastIndex = listItem.tokenList.lastIndex.inc()
listItem.tokenList.fastForEachIndexed { tokenIndex, item ->
SlideInItemVisibility(
currentIndex = tokenIndex + 1,
lastIndex = lastIndex,
modifier = Modifier
.roundedShapeItemDecoration(
radius = 24.dp,
currentIndex = tokenIndex + 1,
addDefaultPadding = false,
lastIndex = lastIndex,
backgroundColor = TangemTheme.colors3.bg.secondary,
),
visible = listItem.isExpanded,
) {
val itemModifier = Modifier
.semantics { lazyListItemPosition = tokenIndex + 1 }
var position by remember { mutableStateOf(Offset.Zero) }
TangemTokenRow(
tokenRowUM = item,
isBalanceHidden = false, // TODO For You
reorderableState = null,
modifier = itemModifier
.onGloballyPositioned {
position = it.positionInWindow()
}
.conditionalCompose(item.onItemClick != null) {
clickable(onClick = requireNotNull(item.onItemClick))
},
)
}
}
}
@Suppress("MagicNumber")
@Composable
private fun PortfolioAssetItem(listItem: ForYouTokenListItemUM, index: Int, outerLastIndex: Int) {
val itemBackgroundColor = TangemTheme.colors3.bg.secondary
ProvideSharedTransitionScope(
modifier = Modifier
.padding(top = 8.dp)
.semantics { lazyListItemPosition = index }
.roundedShapeItemDecoration(
currentIndex = 0,
radius = 24.dp,
addDefaultPadding = false,
lastIndex = portfolioAssetExpandAnimation(listItem = listItem, outerLastIndex = outerLastIndex).value,
backgroundColor = itemBackgroundColor,
),
) {
val iconSharedContentState = rememberSharedContentState(key = "icon_${listItem.tokenRowUM.id}")
val titleSharedContentState = rememberSharedContentState(key = "title_${listItem.tokenRowUM.id}")
val boundsTransform = BoundsTransform { _, _ -> tween(250) }
AnimatedContent(
targetState = listItem.isExpanded,
transitionSpec = { portfolioAssetExpandFadeAnimation() },
) { isExpandedWrapped ->
val composables = remember {
SharedTokenRowComposables(
icon = { modifier ->
PortfolioSharedAssetIcon(
listItem = listItem,
isExpandedWrapped = isExpandedWrapped,
itemBackgroundColor = itemBackgroundColor,
modifier = modifier.sharedBoundsSafely(
sharedContentState = iconSharedContentState,
animatedVisibilityScope = this,
boundsTransform = boundsTransform,
),
)
},
title = { modifier ->
PortfolioSharedAssetTitle(
listItem = listItem,
isExpandedWrapped = isExpandedWrapped,
modifier = modifier.sharedBoundsSafely(
sharedContentState = titleSharedContentState,
animatedVisibilityScope = this,
boundsTransform = boundsTransform,
resizeMode = scaleToBounds(ContentScale.Fit, Alignment.CenterStart),
),
)
},
)
}
if (isExpandedWrapped) {
ForYouPortfolioListHeader(
tokenRowUM = listItem.tokenRowUM,
headComponent = composables.icon,
titleComponent = composables.title,
)
} else {
TangemTokenRow(
tokenRowUM = listItem.tokenRowUM,
headComponent = composables.icon,
titleComponent = composables.title,
isBalanceHidden = false, // todo For You
reorderableState = null,
)
}
}
}
}
@Composable
private fun PortfolioSharedAssetIcon(
listItem: ForYouTokenListItemUM,
isExpandedWrapped: Boolean,
itemBackgroundColor: Color,
modifier: Modifier = Modifier,
) {
val headIcon = listItem.tokenRowUM.headIconUM
if (headIcon is TangemIconUM.Currency) {
val size = if (isExpandedWrapped) {
AccountIconSize.RedesignExtraSmall
} else {
AccountIconSize.RedesignedDefault
}
val currencyIconState = when (val currencyIconState = headIcon.currencyIconState) {
is CurrencyIconState.CryptoPortfolio.Icon -> currencyIconState.copy(size = size)
is CurrencyIconState.CryptoPortfolio.Letter -> currencyIconState.copy(size = size)
else -> currencyIconState
}
TangemCurrencyIcon(
state = currencyIconState,
shouldDisplayNetwork = false,
modifier = modifier
.size(size.toBoxSize())
// TODO For You replace with DC components
.drawWithContent {
drawContent()
if (!isExpandedWrapped) {
val offset = 34.dp.toPx()
drawBadge(
color = Color.Red,
containerColor = itemBackgroundColor,
offset = Offset(
x = offset,
y = offset,
),
size = 3.dp,
padding = 1.dp,
)
}
},
)
}
}
@Composable
private fun PortfolioSharedAssetTitle(
listItem: ForYouTokenListItemUM,
isExpandedWrapped: Boolean,
modifier: Modifier = Modifier,
) {
val targetAnimationFraction = if (isExpandedWrapped) 0f else 1f
val animationFraction = animateFloatAsState(
targetValue = targetAnimationFraction,
animationSpec = tween(durationMillis = 350),
)
val startStyle = TangemTheme.typography3.subheading.medium
val stopStyle = TangemTheme.typography3.body.medium
val textStyle by remember(animationFraction.value) {
derivedStateOf { lerp(startStyle, stopStyle, animationFraction.value) }
}
val resizedTitle = when (val titleUM = listItem.tokenRowUM.titleUM) {
is TangemTokenRowUM.TitleUM.Content -> titleUM.copy(
text = styledStringReference(
titleUM.text.resolveReference(),
{ textStyle.toSpanStyle() },
),
)
else -> titleUM
}
TokenRowTitle(
titleUM = if (isExpandedWrapped) {
(resizedTitle as? TangemTokenRowUM.TitleUM.Content)?.copy(badge = null) ?: resizedTitle
} else {
resizedTitle
},
modifier = modifier,
)
}
@Composable
private fun ForYouPortfolioListHeader(
tokenRowUM: TangemTokenRowUM,
headComponent: @Composable (Modifier) -> Unit,
titleComponent: @Composable (Modifier) -> Unit,
) {
TangemRow(
modifier = Modifier.background(TangemTheme.colors3.bg.secondary),
divider = true,
onClick = tokenRowUM.onItemClick,
verticalAlignment = TangemRowVerticalAlignment.Center,
startSlot = {
headComponent(Modifier)
},
titleSlot = {
titleComponent(Modifier)
val topEndUM = tokenRowUM.topEndContentUM
val bottomEndUM = tokenRowUM.bottomEndContentUM
when {
topEndUM is TangemTokenRowUM.EndContentUM.Content &&
bottomEndUM is TangemTokenRowUM.EndContentUM.Content -> {
Text(
text = annotatedReference {
appendColored(StringsSigns.DOT, TangemTheme.colors3.icon.tertiary)
appendSpace()
append(topEndUM.text.resolveReference())
appendSpace()
appendColored(StringsSigns.DOT, TangemTheme.colors3.icon.tertiary)
appendSpace()
appendColored(bottomEndUM.text.resolveReference(), TangemTheme.colors3.text.secondary)
}.resolveAnnotatedReference(),
style = TangemTheme.typography3.subheading.medium,
color = TangemTheme.colors3.text.primary,
modifier = Modifier.align(Alignment.CenterVertically),
)
}
topEndUM is TangemTokenRowUM.EndContentUM.Loading ||
bottomEndUM is TangemTokenRowUM.EndContentUM.Loading -> {
TextShimmer(style = TangemTheme.typography3.subheading.medium)
}
else -> Unit
}
SpacerWMax()
Icon(
imageVector = Icons.ic_error_20,
tint = TangemTheme.colors3.icon.primary,
contentDescription = null,
)
},
)
}
private fun DrawScope.drawBadge(
containerColor: Color,
color: Color = TangemColorPalette.Azure,
offset: Offset,
size: Dp = 5.dp,
padding: Dp = 2.dp,
) {
drawCircle(
color = containerColor,
center = offset,
radius = size.toPx(),
)
drawCircle(
color = color,
center = offset,
radius = (size - padding).toPx(),
)
}
@Suppress("MagicNumber")
@Composable
private fun portfolioAssetExpandAnimation(listItem: ForYouTokenListItemUM, outerLastIndex: Int): State<Int> {
// Snap immediately on expand; on collapse, hold the current value until all
// child items finish their shrink animation, then snap to fully-rounded shape.
return animateIntAsState(
targetValue = if (listItem.isExpanded) outerLastIndex else 0,
animationSpec = if (listItem.isExpanded) {
snap()
} else {
snap(delayMillis = minOf(50 * maxOf(listItem.tokenList.lastIndex, 0), 250) + 150)
},
label = "lastIndex",
)
}
@Suppress("MagicNumber")
private fun portfolioAssetExpandFadeAnimation(): ContentTransform {
return fadeIn(animationSpec = tween(350, delayMillis = 90))
.togetherWith(fadeOut(animationSpec = tween(350)))
}
@Stable
internal class SharedTokenRowComposables(
val title: @Composable (Modifier) -> Unit,
val icon: @Composable (Modifier) -> Unit,
)

View file

@ -0,0 +1,88 @@
package com.tangem.features.foryou.impl.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
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.unit.dp
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.commonfeatures.api.choosetoken.model.WalletListUM
import com.tangem.features.commonfeatures.api.choosetoken.model.WalletTabUM
/**
* Horizontal wallet pill-tab strip for the For You screen.
*
* Faithful replica of the `WalletTabItem` / `walletListItem` reference in
* `features/common-features/impl/.../choosetoken/ui/ChooseTokenScreen.kt` (which is `private`),
* adapted to a standalone composable (For You renders inside a plain scrollable Column, not a LazyListScope).
*/
@Composable
internal fun WalletTabsBlock(walletList: WalletListUM, modifier: Modifier = Modifier) {
if (walletList.items.isEmpty()) return
LazyRow(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(space = 8.dp),
contentPadding = PaddingValues(horizontal = 16.dp),
) {
items(walletList.items) { um ->
WalletTabItem(um)
}
}
}
@Composable
private fun WalletTabItem(state: WalletTabUM, modifier: Modifier = Modifier) {
val isSelected = state.isSelected
val backgroundColor = if (isSelected) TangemTheme.colors.button.primary else TangemTheme.colors.button.secondary
val buttonTextColor = if (isSelected) TangemTheme.colors.text.primary2 else TangemTheme.colors.text.primary1
val countTextColor = if (isSelected) TangemTheme.colors.text.primary2 else TangemTheme.colors.text.secondary
val countBackground = if (isSelected) {
TangemTheme.colors.button.secondary.copy(alpha = 0.2f)
} else {
TangemTheme.colors.button.primary.copy(alpha = 0.1f)
}
Row(
modifier = modifier
.clip(RoundedCornerShape(percent = 50))
.background(backgroundColor)
.clickable(onClick = state.onClick)
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = state.text.resolveReference(),
color = buttonTextColor,
style = TangemTheme.typography2.bodySemibold16,
)
val count = state.count
if (count != null) {
Spacer(modifier = Modifier.width(8.dp))
Box(
modifier = Modifier
.background(countBackground, shape = CircleShape)
.defaultMinSize(minWidth = 20.dp)
.padding(horizontal = 4.dp, vertical = 2.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = count.resolveReference(),
color = countTextColor,
style = TangemTheme.typography.caption1,
)
}
}
}
}

View file

@ -0,0 +1,144 @@
package com.tangem.features.foryou.impl.ui.preview
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
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.ds.image.TangemIconUM
import com.tangem.core.ui.ds.row.token.TangemTokenRowUM
import com.tangem.core.ui.ds.tabs.TangemSegmentUM
import com.tangem.core.ui.ds.tabs.TangemSegmentedPickerUM
import com.tangem.core.ui.extensions.stringReference
import com.tangem.features.foryou.impl.entity.ForYouTokenListItemUM
import com.tangem.features.foryou.impl.entity.PortfolioReviewUM
import com.tangem.utils.StringsSigns.DOT
import kotlinx.collections.immutable.persistentListOf
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")),
TangemSegmentUM(id = "1", title = stringReference("Week")),
TangemSegmentUM(id = "2", title = stringReference("Month")),
),
initialSelectedItem = TangemSegmentUM(id = "0", title = stringReference("Day")),
isFixed = true,
isAltSurface = true,
),
onPeriodClick = {},
tokenList = persistentListOf(
ForYouTokenListItemUM(
tokenRowUM = TangemTokenRowUM.Content(
id = "network_0",
headIconUM = TangemIconUM.Currency(CurrencyIconState.Loading),
titleUM = TangemTokenRowUM.TitleUM.Content(
text = stringReference("USDC"),
badge = TangemBadgeUM(
text = stringReference("Positive"),
size = TangemBadgeSize.X4,
type = TangemBadgeType.Tinted,
color = TangemBadgeColor.Green,
),
),
subtitleUM = TangemTokenRowUM.SubtitleUM.Content(
text = stringReference("2 networks"),
),
topEndContentUM = TangemTokenRowUM.EndContentUM.Content(
text = stringReference("\$5,479"),
),
bottomEndContentUM = TangemTokenRowUM.EndContentUM.Content(
text = stringReference("54,8%"),
),
onItemClick = {},
onItemLongClick = { _, _ -> },
),
tokenList = persistentListOf(
TangemTokenRowUM.Content(
id = "network_0_token_0",
headIconUM = TangemIconUM.Currency(CurrencyIconState.Loading),
titleUM = TangemTokenRowUM.TitleUM.Content(
text = stringReference("USDC"),
badge = TangemBadgeUM(
text = stringReference("Positive"),
size = TangemBadgeSize.X4,
type = TangemBadgeType.Tinted,
color = TangemBadgeColor.Green,
),
),
subtitleUM = TangemTokenRowUM.SubtitleUM.Content(
text = stringReference("Solana $DOT 3,479 USDC"),
),
topEndContentUM = TangemTokenRowUM.EndContentUM.Content(
text = stringReference("\$3,479"),
),
bottomEndContentUM = TangemTokenRowUM.EndContentUM.Content(
text = stringReference("34,7%"),
),
onItemClick = {},
onItemLongClick = { _, _ -> },
),
TangemTokenRowUM.Content(
id = "network_0_token_1",
headIconUM = TangemIconUM.Currency(CurrencyIconState.Loading),
titleUM = TangemTokenRowUM.TitleUM.Content(
text = stringReference("USDC"),
badge = TangemBadgeUM(
text = stringReference("Positive"),
size = TangemBadgeSize.X4,
type = TangemBadgeType.Tinted,
color = TangemBadgeColor.Green,
),
),
subtitleUM = TangemTokenRowUM.SubtitleUM.Content(
text = stringReference("Ethereum $DOT 2,000 USDC"),
),
topEndContentUM = TangemTokenRowUM.EndContentUM.Content(
text = stringReference("\$2,000"),
),
bottomEndContentUM = TangemTokenRowUM.EndContentUM.Content(
text = stringReference("20,1%"),
),
onItemClick = {},
onItemLongClick = { _, _ -> },
),
),
isExpanded = true,
isExpandable = true,
),
ForYouTokenListItemUM(
tokenRowUM = TangemTokenRowUM.Content(
id = "network_1",
headIconUM = TangemIconUM.Currency(CurrencyIconState.Loading),
titleUM = TangemTokenRowUM.TitleUM.Content(
text = stringReference("Bitcoin"),
badge = TangemBadgeUM(
text = stringReference("Positive"),
size = TangemBadgeSize.X4,
type = TangemBadgeType.Tinted,
color = TangemBadgeColor.Green,
),
),
subtitleUM = TangemTokenRowUM.SubtitleUM.Content(
text = stringReference("Main network"),
),
topEndContentUM = TangemTokenRowUM.EndContentUM.Content(
text = stringReference("\$849"),
),
bottomEndContentUM = TangemTokenRowUM.EndContentUM.Content(
text = stringReference("8,49%"),
),
onItemClick = {},
onItemLongClick = { _, _ -> },
),
tokenList = persistentListOf(),
isExpanded = false,
isExpandable = false,
),
),
)
}

View file

@ -0,0 +1,346 @@
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.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.entity.PortfolioReviewUM
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
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.
every { getSelectedAppCurrencyUseCase() } returns flowOf(AppCurrency.Default.right())
}
@AfterEach
fun tearDown() {
model?.onDestroy()
model = null
}
@Nested
inner class InitialState {
@Test
fun `GIVEN model created WHEN not yet advanced THEN uiState is Loading`() = 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()
}
}
@Nested
inner class ContentState {
@Test
fun `GIVEN wallets and account statuses emitted WHEN advanced THEN uiState becomes Content with tabs`() =
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,
currencies = listOf(createStatus(currency, loadedValue(BigDecimal("100")))),
totalFiatBalance = BigDecimal("100"),
)
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"))
}
}
@Nested
inner class ExpandClick {
@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,
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()
advanceUntilIdle()
// Assert
val expandedContent = model.uiState.value.portfolioReviewUM as PortfolioReviewUM.Content
assertThat(expandedContent.tokenList.single().isExpanded).isTrue()
// Act — click again to collapse
val expandedRow = expandedContent.tokenList.single().tokenRowUM as TangemTokenRowUM.Content
expandedRow.onItemClick?.invoke()
advanceUntilIdle()
// Assert
val collapsedContent = model.uiState.value.portfolioReviewUM as PortfolioReviewUM.Content
assertThat(collapsedContent.tokenList.single().isExpanded).isFalse()
}
}
@Nested
inner class PeriodClick {
@Test
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,
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)
// 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 createModel(testScope: TestScope): ForYouModel {
return ForYouModel(
userWalletsListRepository = userWalletsListRepository,
multiAccountStatusListSupplier = multiAccountStatusListSupplier,
dispatchers = testScope.createTestingCoroutineDispatcherProvider(),
getSelectedAppCurrencyUseCase = getSelectedAppCurrencyUseCase,
walletIconUMConverter = walletIconUMConverter,
getWalletIconUseCase = getWalletIconUseCase,
).also { model = it }
}
private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider {
val testDispatcher = StandardTestDispatcher(testScheduler)
return TestingCoroutineDispatcherProvider(
main = testDispatcher,
mainImmediate = testDispatcher,
io = testDispatcher,
default = testDispatcher,
single = testDispatcher,
)
}
private fun createAccountStatusList(
userWalletId: UserWalletId,
currencies: List<CryptoCurrencyStatus>,
totalFiatBalance: BigDecimal,
): 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,
)
}
private fun createStatus(currency: CryptoCurrency, value: CryptoCurrencyStatus.Value) = CryptoCurrencyStatus(
currency = currency,
value = value,
)
private fun loadedValue(fiatAmount: BigDecimal): CryptoCurrencyStatus.Loaded = mockk {
every { amount } returns BigDecimal.ONE
every { this@mockk.fiatAmount } returns fiatAmount
every { isError } returns false
}
private fun createCoin(rawCurrencyId: String, symbol: String): CryptoCurrency.Coin {
val network: Network = mockk {
every { name } returns "Network"
every { isTestnet } returns false
every { id } returns mockk { every { rawId } returns Network.RawID(rawCurrencyId) }
}
val currencyId: CryptoCurrency.ID = mockk {
every { value } returns "coin-$rawCurrencyId"
every { this@mockk.rawCurrencyId } returns CryptoCurrency.RawID(rawCurrencyId)
}
return mockk<CryptoCurrency.Coin> {
every { this@mockk.id } returns currencyId
every { this@mockk.symbol } returns symbol
every { this@mockk.network } returns network
every { this@mockk.decimals } returns 8
every { isCustom } returns false
every { iconUrl } returns null
}
}
}

View file

@ -0,0 +1,163 @@
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
import io.mockk.mockk
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import java.math.BigDecimal
internal class ForYouPortfolioFormattersTest {
private val appCurrency: AppCurrency = AppCurrency.Default
@Nested
inner class ForYouGroupKey {
@Test
fun `GIVEN standard currency with raw id WHEN forYouGroupKey THEN returns rawCurrencyId value`() {
// Arrange
val id: CryptoCurrency.ID = mockk {
every { rawCurrencyId } returns CryptoCurrency.RawID("bitcoin")
every { value } returns "coin-id-value"
}
val currency: CryptoCurrency = mockk { every { this@mockk.id } returns id }
val status = createStatus(currency)
// Act
val result = status.forYouGroupKey()
// Assert
assertThat(result).isEqualTo("bitcoin")
}
@Test
fun `GIVEN custom token with no raw id WHEN forYouGroupKey THEN falls back to currency id value`() {
// Arrange
val id: CryptoCurrency.ID = mockk {
every { rawCurrencyId } returns null
every { value } returns "custom-currency-id"
}
val currency: CryptoCurrency = mockk { every { this@mockk.id } returns id }
val status = createStatus(currency)
// Act
val result = status.forYouGroupKey()
// Assert
assertThat(result).isEqualTo("custom-currency-id")
}
private fun createStatus(currency: CryptoCurrency): CryptoCurrencyStatus = CryptoCurrencyStatus(
currency = currency,
value = CryptoCurrencyStatus.Loading,
)
}
@Nested
inner class ToForYouFiatText {
@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`() {
// Arrange
val amount: BigDecimal? = null
// Act
val result = amount.toForYouFiatText(appCurrency)
// 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)
}
@Test
fun `GIVEN zero total WHEN toForYouPercentText THEN returns EMPTY`() {
// Arrange
val amount = BigDecimal("10")
// Act
val result = amount.toForYouPercentText(BigDecimal.ZERO)
// Assert
assertThat(result).isEqualTo(TextReference.EMPTY)
}
@Test
fun `GIVEN zero amount WHEN toForYouPercentText THEN returns EMPTY`() {
// Arrange
val amount = BigDecimal.ZERO
// Act
val result = amount.toForYouPercentText(BigDecimal("100"))
// Assert
assertThat(result).isEqualTo(TextReference.EMPTY)
}
@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")
// Act
val result = amount.toForYouPercentText(total)
// Assert — 25.00 / 100 = 0.25 -> 25.00%
assertThat(result).isEqualTo(stringReference("25.00%"))
}
@Test
fun `GIVEN a share requiring rounding WHEN toForYouPercentText THEN applies HALF_UP rounding`() {
// Arrange — 1.0000 / 3 = 0.3333... -> rounds to 33.33%
val amount = BigDecimal("1.0000")
val total = BigDecimal("3")
// Act
val result = amount.toForYouPercentText(total)
// Assert
assertThat(result).isEqualTo(stringReference("33.33%"))
}
}
}

View file

@ -0,0 +1,285 @@
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.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.features.foryou.impl.R
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import java.math.BigDecimal
internal class ForYouTokenListConverterTest {
private val appCurrency: AppCurrency = AppCurrency.Default
@Nested
inner class Convert {
@Test
fun `GIVEN single-network coin WHEN convert THEN subtitle is common main network`() {
// Arrange
val currency = createCoin(rawCurrencyId = "bitcoin", symbol = "BTC", networkId = "bitcoin")
val status = createStatus(currency, loadedValue(BigDecimal("1"), BigDecimal("100")))
val converter = createConverter(totalFiatBalance = BigDecimal("100"))
// Act
val result = converter.convert(listOf(status))
// Assert
val row = result.single().tokenRowUM as TangemTokenRowUM.Content
val subtitle = row.subtitleUM as TangemTokenRowUM.SubtitleUM.Content
assertThat(subtitle.text).isEqualTo(resourceReference(R.string.common_main_network))
}
@Test
fun `GIVEN single-network token WHEN convert THEN subtitle is the network standard type name`() {
// Arrange
val currency = createToken(
rawCurrencyId = "usdc",
symbol = "USDC",
networkId = "ethereum",
standardTypeName = "ERC20",
)
val status = createStatus(currency, loadedValue(BigDecimal("1"), BigDecimal("100")))
val converter = createConverter(totalFiatBalance = BigDecimal("100"))
// Act
val result = converter.convert(listOf(status))
// Assert
val row = result.single().tokenRowUM as TangemTokenRowUM.Content
val subtitle = row.subtitleUM as TangemTokenRowUM.SubtitleUM.Content
assertThat(subtitle.text).isEqualTo(stringReference("ERC20"))
}
@Test
fun `GIVEN asset spans multiple networks WHEN convert THEN subtitle shows network count`() {
// Arrange — same asset (shared rawCurrencyId) on two different networks
val onEth = createToken(rawCurrencyId = "usdc", symbol = "USDC", networkId = "ethereum")
val onSol = createToken(rawCurrencyId = "usdc", symbol = "USDC", networkId = "solana")
val statusEth = createStatus(onEth, loadedValue(BigDecimal("1"), BigDecimal("100")))
val statusSol = createStatus(onSol, loadedValue(BigDecimal("2"), BigDecimal("200")))
val converter = createConverter(
totalFiatBalance = BigDecimal("300"),
)
// Act
val result = converter.convert(listOf(statusEth, statusSol))
// Assert
val item = result.single()
val row = item.tokenRowUM as TangemTokenRowUM.Content
val subtitle = row.subtitleUM as TangemTokenRowUM.SubtitleUM.Content
assertThat(subtitle.text).isEqualTo(stringReference("2 networks"))
assertThat(item.tokenList).hasSize(2)
}
@Test
fun `GIVEN multi-network asset WHEN convert THEN child rows ordered by descending fiat balance`() {
// Arrange
val onEth = createToken(rawCurrencyId = "usdc", symbol = "USDC", networkId = "ethereum")
val onSol = createToken(rawCurrencyId = "usdc", symbol = "USDC", networkId = "solana")
val statusEth = createStatus(onEth, loadedValue(BigDecimal("1"), BigDecimal("100")))
val statusSol = createStatus(onSol, loadedValue(BigDecimal("2"), BigDecimal("500")))
val converter = createConverter(
totalFiatBalance = BigDecimal("600"),
)
// Act
val result = converter.convert(listOf(statusEth, statusSol))
// Assert — Solana holding (500) ranks above Ethereum holding (100)
val childIds = result.single().tokenList.map { it.id }
assertThat(childIds).containsExactly("token-usdc-solana", "token-usdc-ethereum").inOrder()
}
@Test
fun `GIVEN all statuses of an asset are Loading WHEN convert THEN asset row is Loading`() {
// Arrange
val currency = createCoin(rawCurrencyId = "bitcoin", symbol = "BTC", networkId = "bitcoin")
val status = createStatus(currency, CryptoCurrencyStatus.Loading)
val converter = createConverter(totalFiatBalance = BigDecimal.ZERO)
// Act
val result = converter.convert(listOf(status))
// Assert
assertThat(result.single().tokenRowUM).isInstanceOf(TangemTokenRowUM.Loading::class.java)
}
@Test
fun `GIVEN otherAssetCount is zero 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,
)
// Act
val result = converter.convert(listOf(status))
// Assert
assertThat(result).hasSize(1)
}
@Test
fun `GIVEN otherAssetCount is one 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"),
)
// Act
val result = converter.convert(listOf(status))
// Assert
val otherRow = result.last().tokenRowUM as TangemTokenRowUM.Content
assertThat(otherRow.id).isEqualTo("for_you_other_assets")
val subtitle = otherRow.subtitleUM as TangemTokenRowUM.SubtitleUM.Content
assertThat(subtitle.text).isEqualTo(stringReference("1 asset"))
}
@Test
fun `GIVEN otherAssetCount is more than one 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"),
)
// Act
val result = converter.convert(listOf(status))
// Assert
val otherRow = result.last().tokenRowUM as TangemTokenRowUM.Content
val subtitle = otherRow.subtitleUM as TangemTokenRowUM.SubtitleUM.Content
assertThat(subtitle.text).isEqualTo(stringReference("3 assets"))
}
@Test
fun `GIVEN asset id in expandedAssetIds WHEN convert THEN item isExpanded is true`() {
// Arrange
val currency = createCoin(rawCurrencyId = "bitcoin", symbol = "BTC", networkId = "bitcoin")
val status = createStatus(currency, loadedValue(BigDecimal("1"), BigDecimal("100")))
val converter = createConverter(
totalFiatBalance = BigDecimal("100"),
expandedAssetIds = setOf("bitcoin"),
)
// Act
val result = converter.convert(listOf(status))
// Assert
assertThat(result.single().isExpanded).isTrue()
}
@Test
fun `GIVEN asset id not in expandedAssetIds WHEN convert THEN item isExpanded is false`() {
// Arrange
val currency = createCoin(rawCurrencyId = "bitcoin", symbol = "BTC", networkId = "bitcoin")
val status = createStatus(currency, loadedValue(BigDecimal("1"), BigDecimal("100")))
val converter = createConverter(
totalFiatBalance = BigDecimal("100"),
expandedAssetIds = emptySet(),
)
// Act
val result = converter.convert(listOf(status))
// Assert
assertThat(result.single().isExpanded).isFalse()
}
}
private fun createConverter(
totalFiatBalance: BigDecimal,
expandedAssetIds: Set<String> = emptySet(),
otherAssetCount: Int = 0,
otherFiatBalance: BigDecimal = BigDecimal.ZERO,
): ForYouTokenListConverter = ForYouTokenListConverter(
appCurrency = appCurrency,
totalFiatBalance = totalFiatBalance,
expandedAssetIds = expandedAssetIds,
expandClick = {},
otherAssetCount = otherAssetCount,
otherFiatBalance = otherFiatBalance,
)
private fun createStatus(currency: CryptoCurrency, value: CryptoCurrencyStatus.Value) = CryptoCurrencyStatus(
currency = currency,
value = value,
)
private fun loadedValue(amount: BigDecimal, fiatAmount: BigDecimal): CryptoCurrencyStatus.Loaded = mockk {
every { this@mockk.amount } returns amount
every { this@mockk.fiatAmount } returns fiatAmount
every { isError } returns false
}
private fun createCoin(rawCurrencyId: String, symbol: String, networkId: String): CryptoCurrency.Coin {
val network = createNetwork(networkId = networkId, standardTypeName = "MAIN")
val currencyId = createCurrencyId(idValue = "coin-$rawCurrencyId-$networkId", rawCurrencyId = rawCurrencyId)
return mockk<CryptoCurrency.Coin> {
every { this@mockk.id } returns currencyId
every { this@mockk.symbol } returns symbol
every { this@mockk.network } returns network
every { this@mockk.decimals } returns 8
every { isCustom } returns false
every { iconUrl } returns null
}
}
private fun createToken(
rawCurrencyId: String,
symbol: String,
networkId: String,
standardTypeName: String = "ERC20",
): CryptoCurrency.Token {
val network = createNetwork(networkId = networkId, standardTypeName = standardTypeName)
val currencyId = createCurrencyId(idValue = "token-$rawCurrencyId-$networkId", rawCurrencyId = rawCurrencyId)
return mockk<CryptoCurrency.Token> {
every { this@mockk.id } returns currencyId
every { this@mockk.symbol } returns symbol
every { this@mockk.network } returns network
every { this@mockk.decimals } returns 6
every { isCustom } returns false
every { iconUrl } returns null
every { contractAddress } returns "0xCONTRACT"
}
}
private fun createCurrencyId(idValue: String, rawCurrencyId: String): CryptoCurrency.ID = mockk {
every { value } returns idValue
every { this@mockk.rawCurrencyId } returns CryptoCurrency.RawID(rawCurrencyId)
}
private fun createNetwork(networkId: String, standardTypeName: String): Network {
val standardType: Network.StandardType = mockk {
every { name } returns standardTypeName
}
return mockk {
every { id } returns mockk {
every { rawId } returns Network.RawID(networkId)
}
every { name } returns networkId
every { isTestnet } returns false
every { this@mockk.standardType } returns standardType
}
}
}

View file

@ -0,0 +1,131 @@
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.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import java.math.BigDecimal
internal class ForYouTokenRowConverterTest {
private val appCurrency: AppCurrency = AppCurrency.Default
@Nested
inner class ConvertNetworkGroup {
@Test
fun `GIVEN all statuses Loading WHEN convertNetworkGroup 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)
// Assert
assertThat(result).isEqualTo(TangemTokenRowUM.Loading(id = "coin-eth"))
}
@Test
fun `GIVEN single loaded status WHEN convertNetworkGroup THEN row is Content with its amounts`() {
// Arrange
val currency = createCurrency(id = "coin-eth", symbol = "ETH", networkName = "Ethereum")
val statuses = listOf(
createStatus(currency, loadedValue(amount = BigDecimal("2"), fiatAmount = BigDecimal("400"))),
)
val converter = createConverter(totalFiatBalance = BigDecimal("1000"))
// Act
val result = converter.convertNetworkGroup(statuses) as TangemTokenRowUM.Content
// Assert
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")))
}
@Test
fun `GIVEN several statuses of the same asset on one network WHEN convertNetworkGroup 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(
createStatus(currency, loadedValue(amount = BigDecimal("1"), fiatAmount = BigDecimal("200"))),
createStatus(currency, loadedValue(amount = BigDecimal("2"), fiatAmount = BigDecimal("400"))),
)
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.text).isEqualTo(BigDecimal("600").toForYouFiatText(appCurrency))
}
@Test
fun `GIVEN mixed Loading and Loaded statuses WHEN convertNetworkGroup 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(
createStatus(currency, CryptoCurrencyStatus.Loading),
createStatus(currency, loadedValue(amount = BigDecimal("1"), fiatAmount = BigDecimal("100"))),
)
val converter = createConverter(totalFiatBalance = BigDecimal("1000"))
// Act
val result = converter.convertNetworkGroup(statuses)
// Assert
assertThat(result).isInstanceOf(TangemTokenRowUM.Content::class.java)
}
}
private fun createConverter(totalFiatBalance: BigDecimal) = ForYouTokenRowConverter(
appCurrency = appCurrency,
totalFiatBalance = totalFiatBalance,
)
private fun createStatus(currency: CryptoCurrency, value: CryptoCurrencyStatus.Value) = CryptoCurrencyStatus(
currency = currency,
value = value,
)
private fun loadedValue(amount: BigDecimal, fiatAmount: BigDecimal): CryptoCurrencyStatus.Loaded = mockk {
every { this@mockk.amount } returns amount
every { this@mockk.fiatAmount } returns fiatAmount
every { isError } returns false
}
private fun createCurrency(
id: String,
symbol: String,
networkName: String = "Network",
): CryptoCurrency {
val network: Network = mockk {
every { name } returns networkName
every { isTestnet } returns false
every { this@mockk.id } returns mockk { every { rawId } returns Network.RawID(id) }
}
val currencyId: CryptoCurrency.ID = mockk {
every { value } returns id
every { rawCurrencyId } returns null
}
return mockk<CryptoCurrency.Coin> {
every { this@mockk.id } returns currencyId
every { this@mockk.symbol } returns symbol
every { this@mockk.network } returns network
every { this@mockk.decimals } returns 8
every { isCustom } returns false
every { iconUrl } returns null
}
}
}

View file

@ -0,0 +1,243 @@
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.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.features.commonfeatures.api.choosetoken.model.WalletListUM
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 io.mockk.every
import io.mockk.mockk
import kotlinx.collections.immutable.persistentListOf
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import java.math.BigDecimal
internal class SetPortfolioReviewTransformerTest {
private val appCurrency: AppCurrency = AppCurrency.Default
private val walletListUM = WalletListUM(items = persistentListOf())
@Nested
inner class Transform {
@Test
fun `GIVEN currency with zero fiat balance WHEN transform THEN it is dropped from asset count`() {
// Arrange
val zeroBalance = createCurrency(rawCurrencyId = "btc", symbol = "BTC")
val nonZeroBalance = createCurrency(rawCurrencyId = "eth", symbol = "ETH")
val currencies = listOf(
createStatus(zeroBalance, loadedValue(BigDecimal.ZERO)),
createStatus(nonZeroBalance, loadedValue(BigDecimal("100"))),
)
val transformer = createTransformer(currencies = currencies, totalFiatBalance = BigDecimal("100"))
// Act
val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content
// Assert
assertThat(result.assetCount).isEqualTo(stringReference("1 assets"))
}
@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
val onEth = createCurrency(rawCurrencyId = "usdc", symbol = "USDC")
val onSol = createCurrency(rawCurrencyId = "usdc", symbol = "USDC")
val other = createCurrency(rawCurrencyId = "btc", symbol = "BTC")
val currencies = listOf(
createStatus(onEth, loadedValue(BigDecimal("50"))),
createStatus(onSol, loadedValue(BigDecimal("60"))),
createStatus(other, loadedValue(BigDecimal("10"))),
)
val transformer = createTransformer(currencies = currencies, totalFiatBalance = 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"))
}
@Test
fun `GIVEN more than TOP_HOLDINGS_COUNT assets WHEN transform THEN excess assets collapse into Other`() {
// Arrange — 5 distinct assets, top 4 kept individually, 5th collapsed into "Other"
val currencies = (1..5).map { index ->
createStatus(
createCurrency(rawCurrencyId = "asset-$index", symbol = "A$index"),
loadedValue(BigDecimal(100 - index)),
)
}
val transformer = createTransformer(currencies = currencies, totalFiatBalance = BigDecimal("470"))
// Act
val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content
// Assert — tokenList has 4 top asset rows + 1 "Other" row = 5 items
assertThat(result.tokenList).hasSize(5)
assertThat(result.tokenList.last().tokenRowUM.id).isEqualTo("for_you_other_assets")
}
@Test
fun `GIVEN exactly TOP_HOLDINGS_COUNT assets WHEN transform THEN no Other row is appended`() {
// Arrange
val currencies = (1..4).map { index ->
createStatus(
createCurrency(rawCurrencyId = "asset-$index", symbol = "A$index"),
loadedValue(BigDecimal(100 - index)),
)
}
val transformer = createTransformer(currencies = currencies, totalFiatBalance = BigDecimal("394"))
// Act
val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content
// Assert
assertThat(result.tokenList).hasSize(4)
}
@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"))
// Act
val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content
// Assert
assertThat(result.topHoldingPercent).isEqualTo(stringReference("Top holding 100.00%"))
}
@Test
fun `GIVEN zero total fiat balance WHEN transform THEN topHoldingPercent is DASH_SIGN`() {
// Arrange
val currency = createCurrency(rawCurrencyId = "btc", symbol = "BTC")
val currencies = listOf(createStatus(currency, loadedValue(BigDecimal.ZERO)))
val transformer = createTransformer(currencies = currencies, totalFiatBalance = BigDecimal.ZERO)
// Act
val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content
// Assert
assertThat(result.topHoldingPercent).isEqualTo(stringReference("Top holding —"))
}
@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"))
// Act
val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content
// Assert
assertThat(result.periodPickerUM.items.map { it.title }).containsExactly(
stringReference("Day"),
stringReference("Week"),
stringReference("Month"),
).inOrder()
assertThat(result.periodPickerUM.initialSelectedItem?.title).isEqualTo(stringReference("Day"))
}
@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 prevState = ForYouUM(walletListUM = walletListUM, portfolioReviewUM = prevWithWeekSelected)
// Act
val result = transformer.transform(prevState).portfolioReviewUM as PortfolioReviewUM.Content
// Assert
assertThat(result.periodPickerUM.initialSelectedItem).isEqualTo(weekItem)
}
@Test
fun `GIVEN new state WHEN transform THEN walletListUM is applied from constructor`() {
// 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 = {},
)
// Act
val result = transformer.transform(loadingState())
// Assert
assertThat(result.walletListUM).isSameInstanceAs(newWalletListUM)
}
}
private fun createTransformer(
currencies: List<CryptoCurrencyStatus>,
totalFiatBalance: BigDecimal,
expandedAssetIds: Set<String> = emptySet(),
) = SetPortfolioReviewTransformer(
walletListUM = walletListUM,
currencies = currencies,
totalFiatBalance = totalFiatBalance,
appCurrency = appCurrency,
expandedAssetIds = expandedAssetIds,
expandClick = {},
onPeriodClick = {},
)
private fun loadingState(): ForYouUM = ForYouUM(
walletListUM = walletListUM,
portfolioReviewUM = PortfolioReviewUM.Loading(tokenList = persistentListOf<ForYouTokenListItemUM>()),
)
private fun createStatus(currency: CryptoCurrency, value: CryptoCurrencyStatus.Value) = CryptoCurrencyStatus(
currency = currency,
value = value,
)
private fun loadedValue(fiatAmount: BigDecimal): CryptoCurrencyStatus.Loaded = mockk {
every { amount } returns BigDecimal.ONE
every { this@mockk.fiatAmount } returns fiatAmount
every { isError } returns false
}
private fun createCurrency(rawCurrencyId: String, symbol: String): CryptoCurrency.Coin {
val network: Network = mockk {
every { name } returns "Network"
every { isTestnet } returns false
every { id } returns mockk { every { rawId } returns Network.RawID(rawCurrencyId) }
}
val currencyId: CryptoCurrency.ID = mockk {
every { value } returns "coin-$rawCurrencyId"
every { this@mockk.rawCurrencyId } returns CryptoCurrency.RawID(rawCurrencyId)
}
return mockk<CryptoCurrency.Coin> {
every { this@mockk.id } returns currencyId
every { this@mockk.symbol } returns symbol
every { this@mockk.network } returns network
every { this@mockk.decimals } returns 8
every { isCustom } returns false
every { iconUrl } returns null
}
}
}