Updated on 2026-08-14

This commit is contained in:
Tangem 2026-03-31 22:05:23 +05:00
parent bad7b92f65
commit 1f617808db
5 changed files with 302 additions and 116 deletions

View file

@ -16,6 +16,7 @@ import com.tangem.domain.markets.GetMarketsTokenListFlowUseCase
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.markets.TokenMarketListConfig
import com.tangem.domain.markets.toSerializableParam
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
@ -62,6 +63,8 @@ internal class ChooseTokenModel @Inject constructor(
private val visibleMarketItemIds = MutableStateFlow<List<CryptoCurrency.RawID>>(emptyList())
private val visibleDefaultMarketItemIds = MutableStateFlow<List<CryptoCurrency.RawID>>(emptyList())
private val expandedAccountsFlow: MutableStateFlow<Map<AccountId, Boolean>> = MutableStateFlow(emptyMap())
var addToPortfolioManager: AddToPortfolioManager? = null
val addToPortfolioCallback = object : AddToPortfolioComponent.Callback {
override fun onDismiss() = bottomSheetNavigation.dismiss()
@ -104,17 +107,27 @@ internal class ChooseTokenModel @Inject constructor(
flow = bridge.currenciesGroup,
flow2 = settingContextUseCase.invoke(),
flow3 = marketsStateFlow(),
transform = { currenciesGroup, settingContext, marketState ->
flow4 = expandedAccountsFlow,
transform = { currenciesGroup, settingContext, marketState, expandedAccounts ->
val isAccountsMode = settingContext.isAccountsMode
val appCurrency = settingContext.appCurrency
val isBalanceHidden = settingContext.isBalanceHidden
TokensDataConverter(
onSearchEntered = { query -> bridge.onSearchQuery(query) },
onTokenSelected = { tokenId ->
onTokenClick = { tokenId ->
val selected = tokenId to ChooseTokenAnalyticsPayload
.IsSearched(searchQueryState.value.isNotEmpty())
bridge.onTokenSelected(selected)
},
onAccountClick = { account ->
expandedAccountsFlow.update { expandedList ->
val hasSavedAccount = expandedList[account.accountId]
val isExpanded = hasSavedAccount == true
expandedList + (account.accountId to !isExpanded)
}
},
expandedAccounts = expandedAccounts,
tokensDataState = currenciesGroup,
isBalanceHidden = isBalanceHidden,
isAccountsMode = isAccountsMode,

View file

@ -15,16 +15,22 @@ import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.TotalFiatBalance
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.feature.swap.domain.models.ui.AccountSwapAvailability
import com.tangem.utils.converter.Converter
import com.tangem.utils.extensions.orZero
import kotlinx.collections.immutable.toPersistentList
internal class AccountTokenItemConverter(
private val appCurrency: AppCurrency,
private val unavailableErrorText: TextReference,
private val onItemClick: (String) -> Unit,
private val expandedAccounts: Map<AccountId, Boolean>,
private val onTokenItemClick: (String) -> Unit,
private val onAccountItemClick: (Account.CryptoPortfolio) -> Unit,
) : Converter<AccountSwapAvailability, TokensListItemUM.Portfolio> {
override fun convert(value: AccountSwapAvailability): TokensListItemUM.Portfolio {
@ -32,10 +38,15 @@ internal class AccountTokenItemConverter(
tokenItemUM = AccountCryptoPortfolioItemStateConverter(
appCurrency = appCurrency,
account = value.account,
onItemClick = null,
).convert(TotalFiatBalance.Failed),
isExpanded = true,
isCollapsable = false,
onItemClick = onAccountItemClick,
).convert(
TotalFiatBalance.Loaded(
amount = value.currencyList.sumOf { it.cryptoCurrencyStatus.value.fiatAmount.orZero() },
source = StatusSource.ONLY_CACHE,
),
),
isExpanded = expandedAccounts[value.account.accountId] != false,
isCollapsable = true,
tokens = value.currencyList.map { accountSwapCurrency ->
createAvailableItemConverter()
.convert(accountSwapCurrency.cryptoCurrencyStatus)
@ -57,7 +68,7 @@ internal class AccountTokenItemConverter(
fiatAmountStateProvider = {
createFiatAmountStateProvider(status = it, appCurrency = appCurrency, isAvailable = true)
},
onItemClick = { account, currencyStatus -> onItemClick(currencyStatus.currency.id.value) },
onItemClick = { account, currencyStatus -> onTokenItemClick(currencyStatus.currency.id.value) },
)
}

View file

@ -3,6 +3,8 @@ package com.tangem.feature.swap.converters
import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountId
import com.tangem.feature.swap.domain.models.ui.CurrenciesGroup
import com.tangem.feature.swap.models.SwapSelectTokenStateHolder
import com.tangem.feature.swap.models.TokenListUMData
@ -15,7 +17,9 @@ import kotlinx.collections.immutable.toPersistentList
@Suppress("LongParameterList")
internal class TokensDataConverter(
private val onSearchEntered: (String) -> Unit,
private val onTokenSelected: (String) -> Unit,
onTokenClick: (String) -> Unit,
onAccountClick: (Account.CryptoPortfolio) -> Unit,
private val expandedAccounts: Map<AccountId, Boolean>,
private val tokensDataState: CurrenciesGroup,
private val isBalanceHidden: Boolean,
private val isAccountsMode: Boolean,
@ -26,7 +30,9 @@ internal class TokensDataConverter(
private val accountListItemConverter = AccountTokenItemConverter(
appCurrency = appCurrency,
unavailableErrorText = resourceReference(R.string.tokens_list_unavailable_to_swap_source_header),
onItemClick = onTokenSelected,
onTokenItemClick = onTokenClick,
onAccountItemClick = onAccountClick,
expandedAccounts = expandedAccounts,
)
fun transform(): SwapSelectTokenStateHolder {

View file

@ -1,21 +1,16 @@
package com.tangem.feature.swap.ui
import android.content.res.Configuration
import androidx.activity.compose.BackHandler
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.lazy.*
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
@ -30,6 +25,7 @@ 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.SpacerH
import com.tangem.core.ui.components.appbar.ExpandableSearchView
import com.tangem.core.ui.components.list.InfiniteListHandler
import com.tangem.core.ui.components.tokenlist.PortfolioListItem
@ -37,10 +33,11 @@ import com.tangem.core.ui.components.tokenlist.PortfolioTokensListItem
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.*
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.test.BuyTokenScreenTestTags
import com.tangem.core.ui.test.MainScreenTestTags
import com.tangem.core.ui.utils.lazyListItemPosition
import com.tangem.feature.swap.models.SwapSelectTokenStateHolder
import com.tangem.feature.swap.models.TokenListUMData
@ -182,6 +179,10 @@ private fun ListOfTokensWithMarkets(
isBalanceHidden = state.isBalanceHidden,
)
item("spacer_before_markets") {
SpacerH(32.dp)
}
swapMarketsListItems(marketsState)
}
@ -248,10 +249,12 @@ private fun LazyListScope.assetsTitle(count: Int, showCount: Boolean) {
private fun LazyListScope.tokensListItems(tokensListData: TokenListUMData, isBalanceHidden: Boolean) {
when (tokensListData) {
is TokenListUMData.AccountList -> {
tokensListData.tokensList.forEach { item ->
tokensListData.tokensList.forEachIndexed { index, item ->
portfolioTokensList(
portfolio = item,
isBalanceHidden = isBalanceHidden,
portfolioIndex = index,
modifier = Modifier,
)
}
}
@ -287,97 +290,168 @@ private fun LazyListScope.tokensList(items: ImmutableList<TokensListItemUM>, isB
)
}
internal fun LazyListScope.portfolioTokensList(portfolio: TokensListItemUM.Portfolio, isBalanceHidden: Boolean) {
internal fun LazyListScope.portfolioTokensList(
portfolio: TokensListItemUM.Portfolio,
modifier: Modifier,
portfolioIndex: Int,
isBalanceHidden: Boolean,
) {
val tokens = portfolio.tokens
val isExpanded = portfolio.isExpanded
val lastIndex = tokens.lastIndex.inc()
portfolioItem(
portfolio = portfolio,
modifier = Modifier,
modifier = modifier,
portfolioIndex = portfolioIndex,
isBalanceHidden = isBalanceHidden,
)
if (!isExpanded) return
itemsIndexed(
items = tokens,
key = { _, item -> item.id },
key = { _, item -> item.id.toString() + "-portfolio-${portfolio.id}" },
contentType = { _, item -> item::class.java },
itemContent = { tokenIndex, token ->
val indexWithHeader = tokenIndex.inc()
PortfolioTokensListItem(
state = token,
isBalanceHidden = isBalanceHidden,
modifier = Modifier
.animateItem()
SlideInItemVisibility(
currentIndex = tokenIndex,
lastIndex = lastIndex,
modifier = modifier
.testModifier(indexWithHeader)
.animateItem(fadeInSpec = null, placementSpec = null, fadeOutSpec = null)
.roundedShapeItemDecoration(
radius = TangemTheme.dimens.radius14,
currentIndex = indexWithHeader,
lastIndex = tokens.lastIndex.inc(),
lastIndex = lastIndex,
backgroundColor = TangemTheme.colors.background.primary,
)
.conditional(tokenIndex == tokens.lastIndex) {
Modifier.padding(bottom = 8.dp)
},
)
),
visible = isExpanded,
) {
val innerModifier = if (indexWithHeader == lastIndex) Modifier.padding(bottom = 8.dp) else Modifier
PortfolioTokensListItem(
state = token,
isBalanceHidden = isBalanceHidden,
modifier = innerModifier,
)
}
},
)
}
@Suppress("MagicNumber")
private fun LazyListScope.portfolioItem(
portfolio: TokensListItemUM.Portfolio,
modifier: Modifier,
portfolioIndex: Int,
isBalanceHidden: Boolean,
) {
val tokens = portfolio.tokens
val isExpanded = portfolio.isExpanded
val lastIndex = when {
isExpanded && tokens.isEmpty() -> 1
isExpanded -> tokens.lastIndex.inc()
else -> 0
}
item(
key = "account-${portfolio.id}",
contentType = "account",
contentType = "account-content",
) {
// Snap immediately on expand; on collapse, hold until all child items finish
// their shrink animation, then snap to fully-rounded shape.
val effectiveLastIndex by animateIntAsState(
targetValue = lastIndex,
animationSpec = if (lastIndex != 0) {
snap()
} else {
snap(delayMillis = minOf(50 * tokens.lastIndex, 250) + 150)
},
label = "lastIndex",
)
PortfolioListItem(
state = portfolio,
isBalanceHidden = isBalanceHidden,
modifier = Modifier
.animateItem()
modifier = modifier
.testModifier(portfolioIndex)
.roundedShapeItemDecoration(
currentIndex = 0,
lastIndex = portfolio.tokens.lastIndex.inc(),
radius = TangemTheme.dimens.radius14,
lastIndex = effectiveLastIndex,
backgroundColor = TangemTheme.colors.background.primary,
)
.then(modifier),
),
)
}
}
private fun Modifier.testModifier(index: Int): Modifier = this
.testTag(MainScreenTestTags.TOKEN_LIST_ITEM)
.semantics { lazyListItemPosition = index }
@Suppress("MagicNumber")
@Composable
internal fun SlideInItemVisibility(
visible: Boolean,
currentIndex: Int,
lastIndex: Int,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
val maxDelay = 250
val delayEnter = minOf(50 * currentIndex, maxDelay)
val delayExit = minOf(50 * (lastIndex - currentIndex - 1), maxDelay)
AnimatedVisibility(
modifier = modifier,
visible = visible,
enter = expandVertically(
tween(200, delayMillis = delayEnter, easing = LinearOutSlowInEasing),
expandFrom = Alignment.Top,
) + fadeIn(tween(200, delayMillis = delayEnter, easing = LinearOutSlowInEasing)),
exit = shrinkVertically(
tween(150, delayMillis = delayExit, easing = FastOutLinearInEasing),
shrinkTowards = Alignment.Top,
) + fadeOut(tween(150, delayMillis = delayExit, easing = FastOutLinearInEasing)),
) {
content()
}
}
// region Preview
@Composable
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun SwapSelectTokenScreen_Preview(
@PreviewParameter(SwapSelectTokenScreenPreviewProvider::class) params: SwapSelectTokenStateHolder,
) {
TangemThemePreview {
SwapSelectTokenScreen(
state = params,
onBack = {},
)
}
}
private class SwapSelectTokenScreenPreviewProvider : PreviewParameterProvider<SwapSelectTokenStateHolder> {
override val values: Sequence<SwapSelectTokenStateHolder> = sequenceOf(
// Content state with tokens and markets
SwapSelectTokenPreviewProvider().provideSwapSelectTokenState(),
// Empty state
SwapSelectTokenStateHolder(
tokensListData = TokenListUMData.EmptyList,
marketsState = SwapMarketState.DefaultLoading,
isAfterSearch = false,
isBalanceHidden = false,
onSearchEntered = {},
),
// Not found state
SwapSelectTokenStateHolder(
tokensListData = TokenListUMData.EmptyList,
marketsState = SwapMarketState.SearchLoading,
isAfterSearch = true,
isBalanceHidden = false,
onSearchEntered = {},
),
)
}
@Preview
@Composable
private fun TokenScreenPreview(
@PreviewParameter(SwapSelectTokenScreenPreviewProvider::class)
state: SwapSelectTokenStateHolder,
) {
TangemThemePreview {
SwapSelectTokenScreen(
state = state,
onBack = {},
override val values: Sequence<SwapSelectTokenStateHolder>
get() = sequenceOf(
// Content state with tokens and markets
SwapSelectTokenPreviewProvider.defaultState,
// Empty state
SwapSelectTokenStateHolder(
tokensListData = TokenListUMData.EmptyList,
marketsState = SwapMarketState.DefaultLoading,
isAfterSearch = false,
isBalanceHidden = false,
onSearchEntered = {},
),
// Not found state
SwapSelectTokenStateHolder(
tokensListData = TokenListUMData.EmptyList,
marketsState = SwapMarketState.SearchLoading,
isAfterSearch = true,
isBalanceHidden = false,
onSearchEntered = {},
),
)
}
}
}
// endregion

View file

@ -2,30 +2,107 @@ package com.tangem.feature.swap.ui.preview
import com.tangem.common.ui.charts.state.MarketChartRawData
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.core.ui.R
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.components.token.AccountItemPreviewData
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.components.tokenlist.state.PortfolioItemContentUM
import com.tangem.core.ui.components.tokenlist.state.PortfolioTokensListItemUM
import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.core.ui.R
import com.tangem.feature.swap.models.SwapSelectTokenStateHolder
import com.tangem.feature.swap.models.TokenListUMData
import com.tangem.feature.swap.models.market.state.SwapMarketState
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toPersistentList
internal class SwapSelectTokenPreviewProvider {
internal object SwapSelectTokenPreviewProvider {
fun provideSwapSelectTokenState(): SwapSelectTokenStateHolder {
return SwapSelectTokenStateHolder(
tokensListData = TokenListUMData.EmptyList,
isAfterSearch = false,
isBalanceHidden = false,
onSearchEntered = {},
marketsState = createPreviewMarketsState(),
)
}
private const val CHART_VALUE_1 = 0.4
private const val CHART_VALUE_2 = 0.2
private const val CHART_VALUE_3 = 0.1
private const val CHART_VALUE_4 = 2.0
private const val CHART_VALUE_5 = 5.0
private const val CHART_VALUE_6 = 3.0
private const val TOTAL_ITEMS = 322
private fun createPreviewMarketsState() = SwapMarketState.Content(
private val PREVIEW_CHART_DATA = MarketChartRawData(
y = persistentListOf(
CHART_VALUE_1,
CHART_VALUE_2,
CHART_VALUE_1,
CHART_VALUE_3,
CHART_VALUE_1,
CHART_VALUE_4,
CHART_VALUE_5,
CHART_VALUE_3,
CHART_VALUE_4,
CHART_VALUE_4,
CHART_VALUE_6,
),
)
private val tokenItemState = TokenItemState.Content(
id = "1",
iconState = CurrencyIconState.Locked,
titleState = TokenItemState.TitleState.Content(text = stringReference(value = "Bitcoin")),
fiatAmountState = TokenItemState.FiatAmountState.Content(text = "12 368,14 \$"),
subtitle2State = TokenItemState.Subtitle2State.TextContent(text = "0,35853044 BTC"),
subtitleState = TokenItemState.SubtitleState.CryptoPriceContent(
price = "34 496,75 \$",
priceChangePercent = "0,43 %",
type = PriceChangeType.DOWN,
),
onItemClick = {},
onItemLongClick = {},
)
private val textContentTokensState = persistentListOf(
TokensListItemUM.GroupTitle(id = 111, text = stringReference("Network Bitcoin")),
TokensListItemUM.Token(state = tokenItemState),
TokensListItemUM.GroupTitle(id = 222, text = stringReference("Network Ethereum")),
TokensListItemUM.Token(
state = tokenItemState.copy(
id = "2",
titleState = TokenItemState.TitleState.Content(text = stringReference("Ethereum")),
fiatAmountState = TokenItemState.FiatAmountState.Content(text = "3 340,79 \$"),
subtitle2State = TokenItemState.Subtitle2State.TextContent(text = "1,856660295 ETH"),
subtitleState = TokenItemState.SubtitleState.CryptoPriceContent(
price = "1 799,41 \$",
priceChangePercent = "5,16 %",
type = PriceChangeType.UP,
),
),
),
TokensListItemUM.Token(
state = TokenItemState.Unreachable(
id = "3",
iconState = CurrencyIconState.Locked,
titleState = TokenItemState.TitleState.Content(text = stringReference(value = "Polygon")),
onItemClick = {},
onItemLongClick = {},
),
),
TokensListItemUM.Token(
state = tokenItemState.copy(
id = "4",
titleState = TokenItemState.TitleState.Content(text = stringReference(value = "Shiba Inu")),
fiatAmountState = TokenItemState.FiatAmountState.Content(text = "48,64 \$"),
subtitle2State = TokenItemState.Subtitle2State.TextContent(text = "6 200 220,00 SHIB"),
subtitleState = TokenItemState.SubtitleState.CryptoPriceContent(
price = "0.01 \$",
priceChangePercent = "1,34 %",
type = PriceChangeType.DOWN,
),
),
),
)
private val marketState = SwapMarketState.Content(
items = createPreviewMarketItems(),
loadMore = { },
onItemClick = { },
@ -35,6 +112,37 @@ internal class SwapSelectTokenPreviewProvider {
shouldAssetsCount = false,
)
val defaultState = SwapSelectTokenStateHolder(
tokensListData = TokenListUMData.AccountList(
tokensList = persistentListOf(
TokensListItemUM.Portfolio(
content = PortfolioItemContentUM.Tokens(
tokens = textContentTokensState.filterIsInstance<PortfolioTokensListItemUM>()
.toPersistentList(),
),
isExpanded = false,
isCollapsable = true,
tokenItemUM = AccountItemPreviewData.accountItem
.copy(iconState = AccountItemPreviewData.accountLetterIcon),
),
TokensListItemUM.Portfolio(
content = PortfolioItemContentUM.Tokens(
tokens = textContentTokensState.filterIsInstance<PortfolioTokensListItemUM>()
.toPersistentList(),
),
isExpanded = true,
isCollapsable = true,
tokenItemUM = AccountItemPreviewData.accountItem,
),
),
totalTokensCount = TOTAL_ITEMS,
),
isAfterSearch = false,
isBalanceHidden = false,
onSearchEntered = {},
marketsState = marketState,
)
private fun createPreviewMarketItems() = listOf(
createMarketItem(
id = "1",
@ -105,30 +213,4 @@ internal class SwapSelectTokenPreviewProvider {
stakingRate = stringReference("APY 12.34%"),
updateTimestamp = 0,
)
companion object {
private const val CHART_VALUE_1 = 0.4
private const val CHART_VALUE_2 = 0.2
private const val CHART_VALUE_3 = 0.1
private const val CHART_VALUE_4 = 2.0
private const val CHART_VALUE_5 = 5.0
private const val CHART_VALUE_6 = 3.0
private const val TOTAL_ITEMS = 322
private val PREVIEW_CHART_DATA = MarketChartRawData(
y = persistentListOf(
CHART_VALUE_1,
CHART_VALUE_2,
CHART_VALUE_1,
CHART_VALUE_3,
CHART_VALUE_1,
CHART_VALUE_4,
CHART_VALUE_5,
CHART_VALUE_3,
CHART_VALUE_4,
CHART_VALUE_4,
CHART_VALUE_6,
),
)
}
}