Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-01 13:39:26 +05:00
parent 0bfc7eee60
commit bac44ac402
15 changed files with 1503 additions and 6 deletions

View file

@ -20,20 +20,35 @@ dependencies {
/** Features */
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 */
implementation(projects.core.decompose)
implementation(projects.core.ui)
implementation(projects.core.configToggles)
implementation(projects.common.ui)
implementation(deps.compose.ui)
implementation(deps.compose.ui.tooling)
implementation(deps.compose.foundation)
implementation(deps.compose.animation)
implementation(deps.lifecycle.compose)
implementation(deps.compose.material3)
implementation(deps.compose.ui.tooling)
implementation(deps.kotlin.immutable.collections)
/** DI */
implementation(deps.hilt.android)
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.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
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.lifecycle.compose.collectAsStateWithLifecycle
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.components.bottomsheets.state.BottomSheetState
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.resourceReference
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.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.AssistedFactory
import dagger.assisted.AssistedInject
@ -30,8 +36,21 @@ import dagger.assisted.AssistedInject
internal class DefaultForYouComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Suppress("UnusedPrivateMember") @Assisted params: Unit,
private val promoBannersBlockComponentFactory: PromoBannersBlockComponent.Factory,
) : 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
override fun Title(bottomSheetState: State<BottomSheetState>) {
TangemTopBar(
@ -39,7 +58,7 @@ internal class DefaultForYouComponent @AssistedInject constructor(
type = TangemTopBarType.BottomSheet,
startContent = {
Icon(
imageVector = ImageVector.vectorResource(id = R.drawable.ic_arrow_back_28),
imageVector = Icons.ic_chevron_left_20,
contentDescription = null,
tint = TangemTheme.colors3.icon.primary,
modifier = Modifier
@ -62,7 +81,15 @@ internal class DefaultForYouComponent @AssistedInject constructor(
contentPadding: PaddingValues,
modifier: Modifier,
) {
Text("FOR YOU")
val uiState by model.uiState.collectAsStateWithLifecycle()
ForYouContent(
forYouUM = uiState,
bottomSheetState = bottomSheetState,
promoBannersBlockComponent = promoBannersBlockComponent,
contentPadding = contentPadding,
modifier = modifier,
)
}
@AssistedFactory

View file

@ -1,15 +1,19 @@
package com.tangem.features.foryou.impl.di
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.ForYouFeatureToggles
import com.tangem.features.foryou.impl.DefaultForYouComponent
import com.tangem.features.foryou.impl.featuretoggles.DefaultForYouFeatureToggles
import com.tangem.features.foryou.impl.model.ForYouModel
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
import javax.inject.Singleton
@Module
@ -30,4 +34,9 @@ internal interface ForYouComponentModule {
@Binds
@Singleton
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,
),
),
)
}