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

@ -16,7 +16,7 @@ import kotlin.math.max
/**
* A custom layout composable that arranges its children in a row with specific layout IDs.
*/
internal enum class TangemRowLayoutId {
enum class TangemRowLayoutId {
HEAD, START_TOP, END_TOP, START_BOTTOM, END_BOTTOM, TAIL, EXTRA_TOP, EXTRA_BOTTOM
}

View file

@ -63,7 +63,7 @@ object TangemColorPalette {
val Azure = Color(0xFF0099FF)
val Azure_50 = Color(0x800099FF)
val Azure_10 = Color(0x1A0099FF)
// endregion Blue
// endregion Azure
// region Amaranth
val Amaranth = Color(0xFFFF3333)
@ -96,7 +96,7 @@ object TangemColorPalette {
val Eucalyptus_50 = Color(0x800C9F3D)
val Eucalyptus_20 = Color(0x330C9F3D)
val Eucalyptus_10 = Color(0x1A0C9F3D)
// endregion Emerald
// endregion Eucalyptus
// region Overlay
val Overlay1 = Color(0x66000000)

View file

@ -537,9 +537,9 @@ class TangemColors2 internal constructor(
var borderSolidColorAlt by mutableStateOf(borderSolidColorAlt)
private set
var backgroundTintedGreenAlt by mutableStateOf(backgroundTintedGreen)
var backgroundTintedGreenAlt by mutableStateOf(backgroundTintedGreenAlt)
private set
var backgroundSolidGreenAlt by mutableStateOf(backgroundSolidGreen)
var backgroundSolidGreenAlt by mutableStateOf(backgroundSolidGreenAlt)
private set
fun update(other: Markers) {

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,