Updated on 2026-08-14

This commit is contained in:
Tangem 2026-02-20 12:52:07 +05:00
parent 0f961af7ce
commit 8ce04b3949
13 changed files with 1092 additions and 27 deletions

View file

@ -7,10 +7,7 @@ import com.tangem.domain.card.common.util.getCardsCount
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.tokens.error.TokenListError
import com.tangem.feature.wallet.presentation.wallet.domain.WalletAdditionalInfoFactory
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletCardState
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletTokensListState
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletUM
import com.tangem.feature.wallet.presentation.wallet.state.model.*
import com.tangem.feature.wallet.presentation.wallet.state.utils.disableButtons
import timber.log.Timber
import java.math.BigDecimal
@ -53,7 +50,17 @@ internal class SetTokenListErrorTransformer(
}
override fun transform(walletUM: WalletUM): WalletUM {
return walletUM // todo redesign main
return when (walletUM) {
is WalletUM.Content -> {
walletUM.copy(
tokensListUM = WalletTokensListUM.Empty,
)
}
is WalletUM.Locked -> {
Timber.w("Impossible to load tokens list for locked wallet")
walletUM
}
}
}
private fun WalletCardState.toLoadedState(): WalletCardState {

View file

@ -5,12 +5,10 @@ import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.staking.model.StakingAvailability
import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletCardState
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletTokensListState
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletUM
import com.tangem.feature.wallet.presentation.wallet.state.model.*
import com.tangem.feature.wallet.presentation.wallet.state.transformers.converter.MultiWalletCardStateConverter
import com.tangem.feature.wallet.presentation.wallet.state.transformers.converter.TokenListStateConverter
import com.tangem.feature.wallet.presentation.wallet.state.transformers.converter.WalletTokensListUMTransformer
import com.tangem.feature.wallet.presentation.wallet.state.utils.enableButtons
import timber.log.Timber
import java.math.BigDecimal
@ -23,6 +21,7 @@ internal class SetTokenListTransformer(
private val yieldSupplyApyMap: Map<String, BigDecimal> = emptyMap(),
private val stakingAvailabilityMap: Map<CryptoCurrency, StakingAvailability> = emptyMap(),
private val shouldShowMainPromo: Boolean,
private val isAccountsModeEnabled: Boolean,
) : WalletStateTransformer(userWallet.walletId) {
override fun transform(prevState: WalletState): WalletState {
@ -47,7 +46,17 @@ internal class SetTokenListTransformer(
}
override fun transform(walletUM: WalletUM): WalletUM {
return walletUM // todo redesign main
return when (walletUM) {
is WalletUM.Content -> {
walletUM.copy(
tokensListUM = toLoadedState(),
)
}
is WalletUM.Locked -> {
Timber.w("Impossible to load tokens list for locked wallet")
walletUM
}
}
}
private fun WalletCardState.toLoadedState(): WalletCardState {
@ -73,4 +82,19 @@ internal class SetTokenListTransformer(
shouldShowMainPromo = shouldShowMainPromo,
).convert(value = this)
}
private fun toLoadedState(): WalletTokensListUM {
if (params !is TokenConverterParams.Account) return WalletTokensListUM.Empty
return WalletTokensListUMTransformer(
selectedWallet = userWallet,
appCurrency = appCurrency,
clickIntents = clickIntents,
yieldModuleApyMap = yieldSupplyApyMap,
stakingAvailabilityMap = stakingAvailabilityMap,
shouldShowMainPromo = shouldShowMainPromo,
isAccountsModeEnabled = isAccountsModeEnabled,
expandedAccounts = params.expandedAccounts,
).convert(value = params.accountList)
}
}

View file

@ -0,0 +1,139 @@
package com.tangem.feature.wallet.presentation.wallet.state.transformers.converter
import com.tangem.common.ui.R
import com.tangem.common.ui.tokens.TokenItemStateConverter
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.percent
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.currency.yieldSupplyKey
import com.tangem.domain.models.staking.StakingBalance
import com.tangem.domain.staking.model.StakingAvailability
import com.tangem.domain.staking.model.StakingOption
import com.tangem.domain.staking.model.common.RewardInfo
import com.tangem.domain.staking.model.common.RewardType
import com.tangem.feature.wallet.presentation.wallet.state.transformers.converter.EarnApyConverter.EarnApyInfo
import com.tangem.lib.crypto.BlockchainUtils
import com.tangem.utils.converter.Converter
import java.math.BigDecimal
internal class EarnApyConverter(
val yieldModuleApyMap: Map<String, BigDecimal>,
val stakingApyMap: Map<CryptoCurrency, StakingAvailability>,
) : Converter<CryptoCurrencyStatus, EarnApyInfo?> {
override fun convert(value: CryptoCurrencyStatus): EarnApyInfo? {
val token = value.currency as? CryptoCurrency.Token
if (token != null && yieldModuleApyMap.isNotEmpty()) {
val yieldSupplyApy = yieldModuleApyMap.entries.firstOrNull { apy ->
apy.key.equals(
other = token.yieldSupplyKey(),
ignoreCase = BlockchainUtils.isCaseInsensitiveContractAddress(token.network.rawId),
)
}?.value
if (yieldSupplyApy != null) {
val isActive = value.value.yieldSupplyStatus?.isActive == false
return EarnApyInfo(
text = resourceReference(
R.string.yield_module_earn_badge,
wrappedList(yieldSupplyApy),
),
isActive = isActive,
apy = yieldSupplyApy.toString(),
source = TokenItemStateConverter.ApySource.YIELD_SUPPLY,
)
}
}
if (stakingApyMap.isNotEmpty()) {
val stakingInfo = findStakingRate(
currencyStatus = value,
stakingApyMap = stakingApyMap,
)
val rewardTypeRes = when (stakingInfo.rewardType) {
RewardType.APR -> R.string.staking_apr_earn_badge
RewardType.UNKNOWN,
RewardType.APY,
null,
-> R.string.yield_module_earn_badge
}
if (stakingInfo.rate != null) {
val apyString = stakingInfo.rate.format { percent(withPercentSign = false) }
return EarnApyInfo(
text = resourceReference(
rewardTypeRes,
wrappedList(apyString),
),
isActive = stakingInfo.isActive,
apy = apyString,
source = TokenItemStateConverter.ApySource.STAKING,
)
}
}
return null
}
private fun findStakingRate(
currencyStatus: CryptoCurrencyStatus,
stakingApyMap: Map<CryptoCurrency, StakingAvailability>,
): StakingLocalInfo {
val stakingAvailability = stakingApyMap[currencyStatus.currency] as? StakingAvailability.Available
?: return StakingLocalInfo(rate = null, isActive = false, rewardType = null)
val stakingBalance = currencyStatus.value.stakingBalance as? StakingBalance.Data
val stakeKitBalance = stakingBalance as? StakingBalance.Data.StakeKit
val p2pEthPoolBalance = stakingBalance as? StakingBalance.Data.P2PEthPool
val rateInfo = when (val stakingOptions = stakingAvailability.option) {
is StakingOption.P2PEthPool -> {
RewardInfo(
rate = stakingOptions.apy,
type = RewardType.APY,
)
}
is StakingOption.StakeKit -> if (stakeKitBalance != null) {
val validatorsByAddress = stakingOptions.yield.validators.associateBy { it.address }
stakeKitBalance.balance.items
.mapNotNull { it.validatorAddress }
.firstNotNullOfOrNull { address ->
validatorsByAddress[address]?.rewardInfo
} ?: stakingOptions.yield.validators
.filter { it.preferred }
.mapNotNull { validator ->
validator.rewardInfo
}
.maxByOrNull { it.rate }
} else {
stakingOptions.yield.validators
.filter { it.preferred }
.mapNotNull { validator ->
validator.rewardInfo
}
.maxByOrNull { it.rate }
}
}
return StakingLocalInfo(
rate = rateInfo?.rate,
isActive = stakeKitBalance != null || p2pEthPoolBalance != null,
rewardType = rateInfo?.type,
)
}
data class StakingLocalInfo(
val rate: BigDecimal?,
val isActive: Boolean,
val rewardType: RewardType?,
)
data class EarnApyInfo(
val text: TextReference?,
val isActive: Boolean,
val apy: String?,
val source: TokenItemStateConverter.ApySource,
)
}

View file

@ -0,0 +1,521 @@
package com.tangem.feature.wallet.presentation.wallet.state.transformers.converter
import androidx.compose.ui.text.SpanStyle
import com.tangem.common.getTotalCryptoAmount
import com.tangem.common.getTotalFiatAmount
import com.tangem.common.ui.account.AccountIconItemStateConverter
import com.tangem.common.ui.account.toUM
import com.tangem.core.ui.components.account.AccountIconSize
import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.components.marketprice.PriceChangeState
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.ds.badge.*
import com.tangem.core.ui.ds.button.TangemButtonShape
import com.tangem.core.ui.ds.button.TangemButtonSize
import com.tangem.core.ui.ds.button.TangemButtonType
import com.tangem.core.ui.ds.button.TangemButtonUM
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds.row.header.TangemHeaderRowUM
import com.tangem.core.ui.ds.row.token.TangemTokenRowUM
import com.tangem.core.ui.ds.row.token.TangemTokenRowUM.EndContentUM
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.format.bigdecimal.*
import com.tangem.core.ui.res.TangemTheme
import com.tangem.domain.account.models.AccountStatusList
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.card.common.util.cardTypesResolver
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.TotalFiatBalance
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.AccountStatus
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.tokenlist.TokenList
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.staking.model.StakingAvailability
import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents
import com.tangem.feature.wallet.impl.R
import com.tangem.feature.wallet.presentation.wallet.state.model.TokensListItemUM2
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletTokensListUM
import com.tangem.utils.StringsSigns
import com.tangem.utils.converter.Converter
import com.tangem.utils.extensions.addIf
import com.tangem.utils.extensions.orZero
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toPersistentList
import java.math.BigDecimal
@Suppress("LargeClass", "LongParameterList")
internal class WalletTokensListUMTransformer(
private val appCurrency: AppCurrency,
private val selectedWallet: UserWallet,
private val clickIntents: WalletClickIntents,
private val yieldModuleApyMap: Map<String, BigDecimal>,
private val isAccountsModeEnabled: Boolean,
private val expandedAccounts: Set<AccountId>,
stakingAvailabilityMap: Map<CryptoCurrency, StakingAvailability>,
shouldShowMainPromo: Boolean,
) : Converter<AccountStatusList, WalletTokensListUM> {
private val yieldSupplyPromoBannerConverter = YieldSupplyPromoBannerConverter(
yieldModuleApyMap,
shouldShowMainPromo,
)
private val currencyToIconStateConverter = CryptoCurrencyToIconStateConverter()
private val earnApyConverter = EarnApyConverter(
yieldModuleApyMap = yieldModuleApyMap,
stakingApyMap = stakingAvailabilityMap,
)
override fun convert(value: AccountStatusList): WalletTokensListUM {
val promoCryptoCurrency = yieldSupplyPromoBannerConverter.convert2(value = value)
return if (value.accountStatuses.isEmpty()) {
WalletTokensListUM.Empty
} else {
val isCollapsable = value.accountStatuses.count {
it is AccountStatus.CryptoPortfolio && it.account.tokensCount > 0
} > 1
val tokenListUM = value.accountStatuses
.filterIsInstance<AccountStatus.CryptoPortfolio>()
.asSequence()
.flatMap { accountStatus ->
if (isAccountsModeEnabled) {
val isExpanded = expandedAccounts.contains(accountStatus.account.accountId)
sequenceOf(
TokensListItemUM2.Portfolio(
tokenRowUM = toAccountRow(accountStatus, isExpanded),
isExpanded = isExpanded || !isCollapsable,
isCollapsable = isCollapsable,
tokenList = getTokenListItems(
accountStatus.tokenList,
promoCryptoCurrency,
).toPersistentList(),
),
)
} else {
getTokenListItems(accountStatus.tokenList, promoCryptoCurrency)
}
}.toPersistentList()
WalletTokensListUM.Content(
tokenList = tokenListUM,
organizeButtonUM = getOrganizeButtonUM(value),
)
}
}
private fun getTokenListItems(
tokenList: TokenList,
promoCryptoCurrency: CryptoCurrencyStatus?,
): Sequence<TokensListItemUM2> {
return when (tokenList) {
TokenList.Empty -> emptySequence()
is TokenList.GroupedByNetwork -> {
tokenList.groups.asSequence().flatMap { (network, currencies) ->
buildList {
add(
TokensListItemUM2.GroupTitle(
tokenRowUM = toGroupRow(network),
),
)
addAll(
currencies.asSequence().map { currencyStatus ->
val shouldShowPromo = promoCryptoCurrency?.currency?.id == currencyStatus.currency.id
TokensListItemUM2.Token(
tokenRowUM = toCurrencyRow(
currencyStatus = currencyStatus,
shouldShowPromo = shouldShowPromo,
),
)
}.toList(),
)
}
}
}
is TokenList.Ungrouped -> {
tokenList.currencies.asSequence().map { currencyStatus ->
TokensListItemUM2.Token(
toCurrencyRow(
currencyStatus = currencyStatus,
shouldShowPromo = promoCryptoCurrency?.currency?.id == currencyStatus.currency.id,
),
)
}
}
}
}
private fun toAccountRow(accountStatus: AccountStatus.CryptoPortfolio, isExpanded: Boolean): TangemTokenRowUM {
val account = accountStatus.account
val (topEndContent, bottomEndContent) = when (val accountBalance = accountStatus.tokenList.totalFiatBalance) {
TotalFiatBalance.Failed -> toFailedAccountRow()
is TotalFiatBalance.Loaded -> toLoadedAccountRow(accountStatus, accountBalance)
TotalFiatBalance.Loading -> EndContentUM.Loading to EndContentUM.Loading
}
return TangemTokenRowUM.Content(
id = accountStatus.account.accountId.value,
headIconUM = TangemIconUM.Currency(
currencyIconState = AccountIconItemStateConverter(size = AccountIconSize.ExtraSmall).convert(account),
),
titleUM = TangemTokenRowUM.TitleUM.Content(
text = account.accountName.toUM().value,
),
subtitleUM = TangemTokenRowUM.SubtitleUM.Content(
text = pluralReference(
R.plurals.common_tokens_count,
count = account.tokensCount,
formatArgs = wrappedList(account.tokensCount),
),
),
topEndContentUM = topEndContent,
bottomEndContentUM = bottomEndContent,
onItemClick = {
if (isExpanded) {
clickIntents.onAccountCollapseClick(account)
} else {
clickIntents.onAccountExpandClick(account)
}
},
onItemLongClick = null,
)
}
private fun toFailedAccountRow(): Pair<EndContentUM.Content, EndContentUM.Content> {
return EndContentUM.Content(
text = stringReference(StringsSigns.DASH_SIGN),
) to EndContentUM.Content(
text = styledResourceReference(
id = R.string.common_unreachable,
spanStyleReference = { SpanStyle(color = TangemTheme.colors2.text.status.attention) },
),
endIcons = persistentListOf(
TangemIconUM.Icon(
iconRes = R.drawable.ic_attention_default_24,
tintReference = { TangemTheme.colors2.graphic.status.attention },
),
),
)
}
private fun toLoadedAccountRow(
accountStatus: AccountStatus.CryptoPortfolio,
accountBalance: TotalFiatBalance.Loaded,
): Pair<EndContentUM.Content, EndContentUM> {
val priceChange = accountStatus.priceChangeLce.getOrNull()
return EndContentUM.Content(
text = accountBalance.amount.formatStyled {
fiat(
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
spanStyleReference = { SpanStyle(color = TangemTheme.colors2.text.neutral.secondary) },
)
},
) to if (priceChange != null) {
val priceChangeType = PriceChangeType.fromBigDecimal(priceChange.value)
EndContentUM.Content(
text = stringReference(
priceChange.value.format {
fiat(
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
)
},
),
priceChangeUM = PriceChangeState.Content(
type = priceChangeType,
valueInPercent = priceChange.value.format { percent() },
),
)
} else {
EndContentUM.Empty
}
}
private fun toGroupRow(network: Network): TangemHeaderRowUM {
return TangemHeaderRowUM(
id = network.hashCode().toString(),
title = resourceReference(
id = R.string.wallet_network_group_title,
formatArgs = wrappedList(network.name),
),
)
}
private fun toCurrencyRow(currencyStatus: CryptoCurrencyStatus, shouldShowPromo: Boolean): TangemTokenRowUM {
val earnApyInfo = earnApyConverter.convert(currencyStatus)
return TangemTokenRowUM.Content(
id = currencyStatus.currency.id.value,
headIconUM = TangemIconUM.Currency(
currencyIconState = currencyToIconStateConverter.convert(currencyStatus),
),
titleUM = toCurrencyRowTitle(currencyStatus, earnApyInfo),
subtitleUM = toCurrencyRowSubtitle(currencyStatus),
topEndContentUM = toCurrencyRowTopEnd(currencyStatus),
bottomEndContentUM = toCurrencyRowBottomEnd(currencyStatus),
promoBannerUM = toPromoBannerUM(
currencyStatus,
earnApyInfo.takeIf { shouldShowPromo },
),
onItemClick = when (currencyStatus.value) {
CryptoCurrencyStatus.Loading,
is CryptoCurrencyStatus.MissedDerivation,
-> null
else -> {
{
clickIntents.onTokenItemClick(selectedWallet.walletId, currencyStatus)
}
}
},
onItemLongClick = when (currencyStatus.value) {
CryptoCurrencyStatus.Loading -> null
else -> {
{
clickIntents.onTokenItemLongClick(selectedWallet.walletId, currencyStatus)
}
}
},
)
}
private fun toCurrencyRowTitle(
currencyStatus: CryptoCurrencyStatus,
earnApyInfo: EarnApyConverter.EarnApyInfo?,
): TangemTokenRowUM.TitleUM = when (val value = currencyStatus.value) {
is CryptoCurrencyStatus.Loading,
is CryptoCurrencyStatus.MissedDerivation,
is CryptoCurrencyStatus.Unreachable,
is CryptoCurrencyStatus.NoAmount,
-> {
TangemTokenRowUM.TitleUM.Content(
text = stringReference(currencyStatus.currency.name),
)
}
is CryptoCurrencyStatus.Loaded,
is CryptoCurrencyStatus.Custom,
is CryptoCurrencyStatus.NoQuote,
is CryptoCurrencyStatus.NoAccount,
-> {
TangemTokenRowUM.TitleUM.Content(
text = stringReference(currencyStatus.currency.name),
hasPending = value.hasCurrentNetworkTransactions,
badge = if (earnApyInfo != null && earnApyInfo.text != null) {
TangemBadgeUM(
type = TangemBadgeType.Solid,
color = when {
earnApyInfo.isActive -> TangemBadgeColor.Blue
else -> TangemBadgeColor.Gray
},
shape = TangemBadgeShape.Rounded,
size = TangemBadgeSize.X4,
text = earnApyInfo.text,
onClick = if (earnApyInfo.apy != null) {
{
clickIntents.onApyLabelClick(
userWalletId = selectedWallet.walletId,
currencyStatus = currencyStatus,
apySource = earnApyInfo.source,
apy = earnApyInfo.apy,
)
}
} else {
null
},
)
} else {
null
},
)
}
}
private fun toCurrencyRowSubtitle(currencyStatus: CryptoCurrencyStatus): TangemTokenRowUM.SubtitleUM {
return when (currencyStatus.value) {
is CryptoCurrencyStatus.Loading -> TangemTokenRowUM.SubtitleUM.Loading
is CryptoCurrencyStatus.Loaded,
is CryptoCurrencyStatus.Custom,
is CryptoCurrencyStatus.NoQuote,
is CryptoCurrencyStatus.NoAccount,
-> TangemTokenRowUM.SubtitleUM.Content(
text = stringReference(
currencyStatus.value.fiatRate.format {
fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol)
},
),
priceChangeUM = PriceChangeState.Content(
type = PriceChangeType.fromBigDecimal(currencyStatus.value.priceChange.orZero()),
valueInPercent = currencyStatus.value.priceChange.format { percent() },
),
isFlickering = currencyStatus.value.isFlickering(),
)
is CryptoCurrencyStatus.MissedDerivation,
is CryptoCurrencyStatus.Unreachable,
is CryptoCurrencyStatus.NoAmount,
-> TangemTokenRowUM.SubtitleUM.Empty
}
}
private fun toCurrencyRowTopEnd(currencyStatus: CryptoCurrencyStatus): EndContentUM {
val yieldSupply = currencyStatus.value.yieldSupplyStatus
return when (currencyStatus.value) {
is CryptoCurrencyStatus.Loaded,
is CryptoCurrencyStatus.Custom,
is CryptoCurrencyStatus.NoQuote,
is CryptoCurrencyStatus.NoAccount,
-> {
EndContentUM.Content(
text = currencyStatus.getTotalFiatAmount().formatStyled {
fiat(
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
spanStyleReference = { SpanStyle(color = TangemTheme.colors2.text.neutral.secondary) },
)
},
isFlickering = currencyStatus.value.isFlickering(),
startIcons = buildList {
addIf(
element = TangemIconUM.Icon(
iconRes = R.drawable.ic_attention_default_24,
tintReference = { TangemTheme.colors2.graphic.status.attention },
),
condition = yieldSupply?.isActive == true && !yieldSupply.isAllowedToSpend,
)
addIf(
element = TangemIconUM.Icon(
iconRes = R.drawable.ic_error_sync_default_24,
tintReference = { TangemTheme.colors2.graphic.neutral.tertiary },
),
condition = currencyStatus.value.sources.total == StatusSource.ONLY_CACHE,
)
}.toImmutableList(),
)
}
is CryptoCurrencyStatus.Loading -> EndContentUM.Loading
is CryptoCurrencyStatus.MissedDerivation -> EndContentUM.Content(
text = stringReference(StringsSigns.DASH_SIGN),
)
is CryptoCurrencyStatus.Unreachable -> EndContentUM.Content(
text = styledResourceReference(
id = R.string.common_unreachable,
spanStyleReference = { SpanStyle(color = TangemTheme.colors2.text.status.attention) },
),
endIcons = persistentListOf(
TangemIconUM.Icon(
iconRes = R.drawable.ic_attention_default_24,
tintReference = { TangemTheme.colors2.graphic.status.attention },
),
),
)
is CryptoCurrencyStatus.NoAmount,
-> EndContentUM.Empty
}
}
private fun toCurrencyRowBottomEnd(currencyStatus: CryptoCurrencyStatus): EndContentUM {
return when (currencyStatus.value) {
is CryptoCurrencyStatus.Loaded,
is CryptoCurrencyStatus.Custom,
is CryptoCurrencyStatus.NoQuote,
is CryptoCurrencyStatus.NoAccount,
-> EndContentUM.Content(
text = stringReference(
currencyStatus.getTotalCryptoAmount().format {
crypto(cryptoCurrency = currencyStatus.currency)
},
),
isFlickering = currencyStatus.value.isFlickering(),
)
is CryptoCurrencyStatus.Loading -> EndContentUM.Loading
is CryptoCurrencyStatus.MissedDerivation -> EndContentUM.Content(
text = styledResourceReference(
id = R.string.common_no_address,
spanStyleReference = { SpanStyle(color = TangemTheme.colors2.text.status.attention) },
),
endIcons = persistentListOf(
TangemIconUM.Icon(
iconRes = R.drawable.ic_attention_default_24,
tintReference = { TangemTheme.colors2.graphic.status.attention },
),
),
)
is CryptoCurrencyStatus.Unreachable -> EndContentUM.Content(
text = styledResourceReference(
id = R.string.common_unreachable,
spanStyleReference = { SpanStyle(color = TangemTheme.colors2.text.status.attention) },
),
endIcons = persistentListOf(
TangemIconUM.Icon(
iconRes = R.drawable.ic_attention_default_24,
tintReference = { TangemTheme.colors2.graphic.status.attention },
),
),
)
is CryptoCurrencyStatus.NoAmount,
-> EndContentUM.Empty
}
}
private fun toPromoBannerUM(
currencyStatus: CryptoCurrencyStatus,
earnApyInfo: EarnApyConverter.EarnApyInfo?,
): TangemTokenRowUM.PromoBannerUM {
val currency = currencyStatus.currency
val isTokenCurrency = currency is CryptoCurrency.Token
val isCurrencyStatusLoaded = currencyStatus.value is CryptoCurrencyStatus.Loaded
val isApyInfoNotNull = earnApyInfo != null && earnApyInfo.apy != null
if (!isTokenCurrency || !isCurrencyStatusLoaded || !isApyInfoNotNull) {
return TangemTokenRowUM.PromoBannerUM.Empty
}
return TangemTokenRowUM.PromoBannerUM.Content(
title = resourceReference(
R.string.yield_module_main_screen_promo_banner_message,
wrappedList(earnApyInfo.apy),
),
onPromoBannerClick = {
clickIntents.onYieldPromoClicked(currency)
clickIntents.onApyLabelClick(
userWalletId = selectedWallet.walletId,
currencyStatus = currencyStatus,
apySource = earnApyInfo.source,
apy = earnApyInfo.apy,
)
},
onCloseClick = clickIntents::onYieldPromoCloseClick,
onPromoShown = {
clickIntents.onYieldPromoShown(currency)
},
)
}
private fun getOrganizeButtonUM(accountList: AccountStatusList): TangemButtonUM? {
return if (accountList.flattenCurrencies().size > 1 && !isSingleCurrencyWalletWithToken()) {
TangemButtonUM(
text = resourceReference(R.string.organize_tokens_title),
isEnabled = accountList.totalFiatBalance !is TotalFiatBalance.Loading,
size = TangemButtonSize.X9,
shape = TangemButtonShape.Rounded,
type = TangemButtonType.PrimaryInverse,
iconRes = R.drawable.ic_filter_default_24,
onClick = clickIntents::onOrganizeTokensClick,
)
} else {
null
}
}
private fun CryptoCurrencyStatus.Value.isFlickering(): Boolean = sources.total == StatusSource.CACHE
private fun isSingleCurrencyWalletWithToken(): Boolean {
return selectedWallet is UserWallet.Cold &&
selectedWallet.scanResponse.cardTypesResolver.isSingleWalletWithToken()
}
}

View file

@ -1,5 +1,6 @@
package com.tangem.feature.wallet.presentation.wallet.state.transformers.converter
import com.tangem.domain.account.models.AccountStatusList
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.currency.yieldSupplyKey
@ -28,6 +29,35 @@ internal class YieldSupplyPromoBannerConverter(
if (cryptoCurrencyStatuses.any { it.value.yieldSupplyStatus?.isActive == true }) return null
if (yieldModuleApyMap.isEmpty()) return null
val max = cryptoCurrencyStatuses.asSequence()
.mapNotNull { status ->
val token = status.currency as? CryptoCurrency.Token ?: return@mapNotNull null
val shouldIgnoreCase = BlockchainUtils.isCaseInsensitiveContractAddress(token.network.rawId)
val tokenKey = "${token.network.rawId}_${token.contractAddress}"
val matchedKey = yieldModuleApyMap.keys.firstOrNull { mapKey ->
mapKey.equals(tokenKey, shouldIgnoreCase)
} ?: return@mapNotNull null
status to matchedKey
}
.maxByOrNull { (status, _) -> status.value.amount ?: BigDecimal.ZERO }
return max?.first
}
fun convert2(value: AccountStatusList): CryptoCurrencyStatus? {
if (!shouldShowMainPromo) return null
val currencies = value.flattenCurrencies().filter { status ->
status.value is CryptoCurrencyStatus.Loaded
}
val cryptoCurrencyStatuses = currencies.filter { it.currency is CryptoCurrency.Token }
if (cryptoCurrencyStatuses.any { it.value.yieldSupplyStatus?.isActive == true }) return null
if (yieldModuleApyMap.isEmpty()) return null
val max = cryptoCurrencyStatuses.asSequence()
.mapNotNull { status ->
val token = status.currency as? CryptoCurrency.Token ?: return@mapNotNull null

View file

@ -1,5 +1,6 @@
package com.tangem.feature.wallet.presentation.wallet.subscribers
import com.tangem.core.ui.DesignFeatureToggles
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
@ -37,6 +38,7 @@ internal class AccountListSubscriber @AssistedInject constructor(
private val yieldSupplyApyFlowUseCase: YieldSupplyApyFlowUseCase,
private val stakingAvailabilityListUseCase: StakingAvailabilityListUseCase,
private val yieldSupplyGetShouldShowMainPromoUseCase: YieldSupplyGetShouldShowMainPromoUseCase,
private val designFeatureToggles: DesignFeatureToggles,
) : BasicAccountListSubscriber() {
override fun create(coroutineScope: CoroutineScope): Flow<*> = combine7(
@ -51,15 +53,27 @@ internal class AccountListSubscriber @AssistedInject constructor(
accountList, appCurrency, expandedAccounts, isAccountMode,
yieldSupplyApyMap, shouldShowMainPromo, stakingAvailabilityMap,
->
updateState(
accountList = accountList,
appCurrency = appCurrency,
expandedAccounts = expandedAccounts,
isAccountMode = isAccountMode,
yieldSupplyApyMap = yieldSupplyApyMap,
stakingAvailabilityMap = stakingAvailabilityMap,
shouldShowMainPromo = shouldShowMainPromo,
)
if (designFeatureToggles.isRedesignEnabled) {
updateState2(
accountList = accountList,
appCurrency = appCurrency,
expandedAccounts = expandedAccounts,
isAccountMode = isAccountMode,
yieldSupplyApyMap = yieldSupplyApyMap,
stakingAvailabilityMap = stakingAvailabilityMap,
shouldShowMainPromo = shouldShowMainPromo,
)
} else {
updateState(
accountList = accountList,
appCurrency = appCurrency,
expandedAccounts = expandedAccounts,
isAccountMode = isAccountMode,
yieldSupplyApyMap = yieldSupplyApyMap,
stakingAvailabilityMap = stakingAvailabilityMap,
shouldShowMainPromo = shouldShowMainPromo,
)
}
}
private fun stakingAvailabilityFlow(): Flow<Map<CryptoCurrency, StakingAvailability>> = getAccountStatusListFlow()

View file

@ -85,6 +85,29 @@ internal abstract class BasicAccountListSubscriber : BasicWalletSubscriber() {
}
}
protected fun updateState2(
accountList: AccountStatusList,
appCurrency: AppCurrency,
expandedAccounts: Set<AccountId>,
isAccountMode: Boolean,
yieldSupplyApyMap: Map<String, BigDecimal> = emptyMap(),
stakingAvailabilityMap: Map<CryptoCurrency, StakingAvailability> = emptyMap(),
shouldShowMainPromo: Boolean = false,
) {
stateController.update(
SetTokenListTransformer(
params = TokenConverterParams.Account(accountList, expandedAccounts),
userWallet = userWallet,
appCurrency = appCurrency,
clickIntents = clickIntents,
yieldSupplyApyMap = yieldSupplyApyMap,
stakingAvailabilityMap = stakingAvailabilityMap,
shouldShowMainPromo = shouldShowMainPromo,
isAccountsModeEnabled = isAccountMode,
),
)
}
private fun singleAccountTransform(
maybeTokenList: Lce<TokenListError, TokenList>,
appCurrency: AppCurrency,
@ -141,6 +164,7 @@ internal abstract class BasicAccountListSubscriber : BasicWalletSubscriber() {
yieldSupplyApyMap = yieldSupplyApyMap,
stakingAvailabilityMap = stakingAvailabilityMap,
shouldShowMainPromo = shouldShowMainPromo,
isAccountsModeEnabled = false,
),
)
}

View file

@ -143,6 +143,7 @@ internal abstract class BasicTokenListSubscriber(
yieldSupplyApyMap = yieldSupplyApyMap,
stakingAvailabilityMap = stakingAvailabilityMap,
shouldShowMainPromo = shouldShowMainPromo,
isAccountsModeEnabled = false,
),
)
}

View file

@ -163,7 +163,7 @@ private fun LazyListScope.portfolioItem(
@Suppress("MagicNumber")
@Composable
private fun SlideInItemVisibility(
internal fun SlideInItemVisibility(
visible: Boolean,
currentIndex: Int,
lastIndex: Int,

View file

@ -1,5 +1,9 @@
package com.tangem.feature.wallet.presentation.wallet.ui.components.multicurrency
import androidx.compose.animation.*
import androidx.compose.animation.SharedTransitionScope.ResizeMode.Companion.scaleToBounds
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
@ -8,22 +12,37 @@ import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.lerp
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEachIndexed
import com.tangem.core.ui.components.account.AccountIconSize
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.tokenlist.TokenListItem
import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM
import com.tangem.core.ui.decorations.roundedShapeItemDecoration
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.ds.image.TangemIcon
import com.tangem.core.ui.ds.row.header.TangemHeaderRow
import com.tangem.core.ui.ds.row.header.TangemHeaderRowUM
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.extensions.*
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.test.MainScreenTestTags
import com.tangem.core.ui.utils.ProvideSharedTransitionScope
import com.tangem.core.ui.utils.lazyListItemPosition
import com.tangem.feature.wallet.impl.R
import com.tangem.feature.wallet.presentation.wallet.state.model.TokensListItemUM2
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletTokensListState
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletTokensListUM
import kotlinx.collections.immutable.ImmutableList
internal const val NON_CONTENT_TOKENS_LIST_KEY = "NON_CONTENT_TOKENS_LIST"
@ -59,6 +78,180 @@ internal fun LazyListScope.tokensListItems(
}
}
/**
* LazyList extension for [WalletTokensListState]
*
* @param walletTokensListUM state
* @param modifier modifier
*
[REDACTED_AUTHOR]
*/
internal fun LazyListScope.tokensListItems2(
walletTokensListUM: WalletTokensListUM,
modifier: Modifier = Modifier,
isBalanceHidden: Boolean,
) {
when (walletTokensListUM) {
is WalletTokensListUM.Loading,
is WalletTokensListUM.Content,
-> {
walletTokensListUM.tokenList.fastForEachIndexed { index, listItem ->
when (listItem) {
is TokensListItemUM2.GroupTitle,
is TokensListItemUM2.Token,
-> tokenItem(
listItem = listItem,
index = index,
lastIndex = walletTokensListUM.tokenList.lastIndex,
isBalanceHidden = isBalanceHidden,
modifier = modifier,
)
is TokensListItemUM2.Portfolio -> portfolioItem(
listItem = listItem,
index = index,
isBalanceHidden = isBalanceHidden,
modifier = modifier,
)
}
}
}
WalletTokensListUM.Empty -> nonContentItem(modifier = modifier)
}
}
private fun LazyListScope.tokenItem(
listItem: TokensListItemUM2,
index: Int,
lastIndex: Int,
isBalanceHidden: Boolean,
modifier: Modifier = Modifier,
) {
item(
key = listItem.tokenRowUM.id,
contentType = listItem.tokenRowUM::class.java,
) {
val itemModifier = modifier
.testTag(MainScreenTestTags.TOKEN_LIST_ITEM)
.semantics { lazyListItemPosition = index }
.padding(top = if (index == 0) TangemTheme.dimens2.x3 else 0.dp)
.roundedShapeItemDecoration(
radius = 18.dp,
currentIndex = index,
addDefaultPadding = false,
lastIndex = lastIndex,
backgroundColor = TangemTheme.colors2.surface.level3,
)
when (val tokenRowUM = listItem.tokenRowUM) {
is TangemTokenRowUM -> TangemTokenRow(
tokenRowUM = tokenRowUM,
isBalanceHidden = isBalanceHidden,
reorderableTokenListState = null,
modifier = itemModifier,
)
is TangemHeaderRowUM -> TangemHeaderRow(
headerRowUM = tokenRowUM,
modifier = itemModifier,
)
}
}
}
private fun LazyListScope.portfolioItem(
listItem: TokensListItemUM2.Portfolio,
index: Int,
isBalanceHidden: Boolean,
modifier: Modifier,
) {
val lastIndex = listItem.tokenList.lastIndex + 1
accountItem(
listItem = listItem,
modifier = modifier,
index = index,
lastIndex = lastIndex,
isBalanceHidden = isBalanceHidden,
)
itemsIndexed(
items = listItem.tokenList,
key = { _, item -> item.tokenRowUM.id },
contentType = { _, item -> item::class.java },
itemContent = { tokenIndex, item ->
SlideInItemVisibility(
currentIndex = tokenIndex + 1,
lastIndex = lastIndex,
modifier = modifier
.animateItem(fadeInSpec = null, placementSpec = null, fadeOutSpec = null)
.roundedShapeItemDecoration(
radius = 18.dp,
currentIndex = tokenIndex + 1,
addDefaultPadding = false,
lastIndex = lastIndex,
backgroundColor = TangemTheme.colors2.surface.level3,
),
visible = listItem.isExpanded,
) {
val itemModifier = Modifier
.testTag(MainScreenTestTags.TOKEN_LIST_ITEM)
.semantics { lazyListItemPosition = tokenIndex + 1 }
when (val tokenRowUM = item.tokenRowUM) {
is TangemTokenRowUM -> TangemTokenRow(
tokenRowUM = tokenRowUM,
isBalanceHidden = isBalanceHidden,
reorderableTokenListState = null,
modifier = itemModifier,
)
is TangemHeaderRowUM -> TangemHeaderRow(
headerRowUM = tokenRowUM,
modifier = itemModifier,
)
}
}
},
)
}
private fun LazyListScope.accountItem(
listItem: TokensListItemUM2.Portfolio,
modifier: Modifier,
index: Int,
lastIndex: Int,
isBalanceHidden: Boolean,
) {
item(
key = listItem.tokenRowUM.id,
contentType = listItem.tokenRowUM::class.java,
) {
val portfolioModifier = modifier
.padding(top = if (index != 0) TangemTheme.dimens2.x2 else TangemTheme.dimens2.x3)
.testTag(MainScreenTestTags.TOKEN_LIST_ITEM)
.semantics { lazyListItemPosition = index }
.roundedShapeItemDecoration(
currentIndex = 0,
radius = 18.dp,
addDefaultPadding = false,
lastIndex = if (listItem.isExpanded) lastIndex else 0,
backgroundColor = TangemTheme.colors2.surface.level3,
)
if (listItem.isCollapsable) {
PortfolioRowItem(
item = listItem,
isBalanceHidden = isBalanceHidden,
modifier = portfolioModifier,
)
} else {
TangemHeaderRow(
title = (listItem.tokenRowUM.titleUM as? TangemTokenRowUM.TitleUM.Content)?.text.orEmpty(),
subtitle = (listItem.tokenRowUM.topEndContentUM as? TangemTokenRowUM.EndContentUM.Content)
?.text?.orMaskWithStars(isBalanceHidden),
headTangemIconUM = listItem.tokenRowUM.headIconUM,
modifier = portfolioModifier,
)
}
}
}
private fun LazyListScope.contentItems(
items: ImmutableList<TokensListItemUM>,
modifier: Modifier = Modifier,
@ -77,6 +270,7 @@ private fun LazyListScope.contentItems(
currentIndex = index,
lastIndex = items.lastIndex,
backgroundColor = TangemTheme.colors.background.primary,
radius = 18.dp,
)
.testTag(MainScreenTestTags.TOKEN_LIST_ITEM)
.semantics { lazyListItemPosition = index },
@ -85,6 +279,117 @@ private fun LazyListScope.contentItems(
)
}
@Suppress("MagicNumber", "ReusedModifierInstance", "LongMethod")
@Composable
internal fun PortfolioRowItem(
item: TokensListItemUM2.Portfolio,
isBalanceHidden: Boolean,
modifier: Modifier = Modifier,
) {
// TangemSharedTransitionLayout {
ProvideSharedTransitionScope(modifier) {
val iconSharedContentState = rememberSharedContentState(key = "icon")
val titleSharedContentState = rememberSharedContentState(key = "title")
val boundsTransform = BoundsTransform { _, _ -> tween(250) }
AnimatedContent(
item.isExpanded,
transitionSpec = {
fadeIn(animationSpec = tween(350, delayMillis = 90))
.togetherWith(fadeOut(animationSpec = tween(350)))
},
) { isExpandedWrapped ->
val animatedContentScope = this
val composables = remember {
SharedTokenRowComposables(
icon = { modifier ->
val size = if (isExpandedWrapped) AccountIconSize.ExtraSmall else AccountIconSize.Default
val currencyIconState =
when (val currencyIconState = item.tokenRowUM.headIconUM.currencyIconState) {
is CurrencyIconState.CryptoPortfolio.Icon ->
currencyIconState.copy(size = size)
is CurrencyIconState.CryptoPortfolio.Letter ->
currencyIconState.copy(size = size)
else -> currencyIconState
}
TangemIcon(
tangemIconUM = item.tokenRowUM.headIconUM.copy(currencyIconState = currencyIconState),
modifier = modifier.sharedBounds(
sharedContentState = iconSharedContentState,
animatedVisibilityScope = animatedContentScope,
boundsTransform = boundsTransform,
),
)
},
title = { modifier ->
val targetAnimationFraction = if (isExpandedWrapped) 0f else 1f
val animationFraction = animateFloatAsState(
targetValue = targetAnimationFraction,
animationSpec = tween(durationMillis = 350),
)
val startStyle = TangemTheme.typography2.captionSemibold12
val stopStyle = TangemTheme.typography2.bodySemibold16
val textStyle by remember(animationFraction.value) {
derivedStateOf { lerp(startStyle, stopStyle, animationFraction.value) }
}
val resizedTitle = when (val titleUM = item.tokenRowUM.titleUM) {
is TangemTokenRowUM.TitleUM.Content -> titleUM.copy(
text = styledStringReference(
titleUM.text.resolveReference(),
{ textStyle.toSpanStyle() },
),
)
else -> titleUM
}
TokenRowTitle(
titleUM = resizedTitle,
modifier = modifier.sharedBounds(
sharedContentState = titleSharedContentState,
animatedVisibilityScope = animatedContentScope,
boundsTransform = boundsTransform,
resizeMode = scaleToBounds(ContentScale.Fit, Alignment.CenterStart),
),
)
},
)
}
if (isExpandedWrapped) {
TangemHeaderRow(
subtitle = (item.tokenRowUM.topEndContentUM as? TangemTokenRowUM.EndContentUM.Content)
?.text?.orMaskWithStars(isBalanceHidden),
titleContent = composables.title,
headContent = composables.icon,
footerTangemIconRes = R.drawable.ic_minimize_24,
onItemClick = item.tokenRowUM.onItemClick,
)
} else {
TangemTokenRow(
tokenRowUM = item.tokenRowUM,
headComponent = composables.icon,
titleComponent = composables.title,
isBalanceHidden = isBalanceHidden,
reorderableTokenListState = null,
)
}
}
// }
}
}
@Stable
class SharedTokenRowComposables(
val title: @Composable (Modifier) -> Unit,
val icon: @Composable (Modifier) -> Unit,
)
private fun LazyListScope.nonContentItem(modifier: Modifier = Modifier) {
item(
key = NON_CONTENT_TOKENS_LIST_KEY,