Updated on 2026-08-14

This commit is contained in:
Tangem 2026-01-13 15:41:45 +03:00
parent 8541119c1a
commit 7703758e56
3 changed files with 362 additions and 63 deletions

View file

@ -7,10 +7,16 @@ import androidx.compose.foundation.clickable
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.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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
@ -20,13 +26,16 @@ import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
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.SpacerH12
import com.tangem.core.ui.components.SpacerH32
import com.tangem.core.ui.components.SpacerW2
import com.tangem.core.ui.components.appbar.ExpandableSearchView
import com.tangem.core.ui.components.list.InfiniteListHandler
import com.tangem.core.ui.components.atoms.text.EllipsisText
import com.tangem.core.ui.components.currency.icon.CurrencyIcon
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.tokenlist.PortfolioListItem
import com.tangem.core.ui.components.tokenlist.PortfolioTokensListItem
import com.tangem.core.ui.components.tokenlist.TokenListItem
@ -38,12 +47,18 @@ import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.test.BuyTokenScreenTestTags
import com.tangem.core.ui.utils.lazyListItemPosition
import com.tangem.feature.swap.models.SwapSelectTokenStateHolder
import com.tangem.feature.swap.models.TokenBalanceData
import com.tangem.feature.swap.models.TokenListUMData
import com.tangem.feature.swap.models.TokenToSelectState
import com.tangem.feature.swap.models.isEmptyState
import com.tangem.feature.swap.models.isNotFoundState
import com.tangem.feature.swap.models.market.state.SwapMarketState
import com.tangem.feature.swap.presentation.R
import com.tangem.feature.swap.ui.market.swapMarketsListItems
import com.tangem.feature.swap.ui.preview.SwapSelectTokenPreviewProvider
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.persistentListOf
private const val LOAD_MORE_BUFFER = 25
@Composable
internal fun SwapSelectTokenScreen(state: SwapSelectTokenStateHolder, onBack: () -> Unit) {
@ -56,17 +71,9 @@ internal fun SwapSelectTokenScreen(state: SwapSelectTokenStateHolder, onBack: ()
content = { padding ->
val modifier = Modifier.padding(padding)
when {
state.availableTokens.isEmpty() && state.unavailableTokens.isEmpty() &&
state.tokensListData.tokensList.isEmpty() && state.isAfterSearch -> {
TokensNotFound(modifier)
}
state.availableTokens.isEmpty() && state.unavailableTokens.isEmpty() &&
state.tokensListData.tokensList.isEmpty() && !state.isAfterSearch -> {
EmptyTokensList(modifier)
}
else -> {
ListOfTokens(state = state, modifier = modifier)
}
state.isNotFoundState -> TokensNotFound(modifier)
state.isEmptyState -> EmptyTokensList(modifier)
else -> ListOfTokens(state = state, modifier = modifier)
}
},
topBar = {
@ -135,19 +142,24 @@ private fun TokensNotFound(modifier: Modifier = Modifier) {
@Composable
private fun ListOfTokens(state: SwapSelectTokenStateHolder, modifier: Modifier = Modifier) {
val screenBackgroundColor = TangemTheme.colors.background.secondary
val lazyListState = rememberLazyListState()
LazyColumn(
modifier = modifier
.background(color = screenBackgroundColor)
.fillMaxSize()
.imePadding(),
horizontalAlignment = Alignment.CenterHorizontally,
state = lazyListState,
) {
when (val list = state.tokensListData) {
is TokenListUMData.AccountList -> list.tokensList.forEach { item ->
portfolioTokensList(
portfolio = item,
isBalanceHidden = state.isBalanceHidden,
)
is TokenListUMData.AccountList -> {
list.tokensList.forEach { item ->
portfolioTokensList(
portfolio = item,
isBalanceHidden = state.isBalanceHidden,
)
}
}
is TokenListUMData.TokenList -> {
tokensList(
@ -158,13 +170,56 @@ private fun ListOfTokens(state: SwapSelectTokenStateHolder, modifier: Modifier =
TokenListUMData.EmptyList -> Unit
}
tokensToSelectItems(state.availableTokens, state.onTokenSelected)
if (state.availableTokens.isNotEmpty()) {
tokensToSelectItems(state.availableTokens, state.onTokenSelected)
}
if (state.unavailableTokens.isNotEmpty()) {
item { SpacerH12() }
tokensToSelectItems(state.unavailableTokens, state.onTokenSelected)
}
item { SpacerH12() }
if (state.availableTokens.isNotEmpty() || state.unavailableTokens.isNotEmpty()) {
item { SpacerH32() }
} else {
item { SpacerH12() }
}
tokensToSelectItems(state.unavailableTokens, state.onTokenSelected)
state.marketsState?.let(::swapMarketsListItems)
}
item { SpacerH12() }
(state.marketsState as? SwapMarketState.Content)?.let { marketState ->
VisibleItemsTracker(
lazyListState = lazyListState,
marketState = marketState,
)
InfiniteListHandler(
listState = lazyListState,
buffer = LOAD_MORE_BUFFER,
triggerLoadMoreCheckOnItemsCountChange = true,
onLoadMore = remember(state) {
{
state.marketsState.loadMore()
true
}
},
)
}
}
@Composable
private fun VisibleItemsTracker(lazyListState: LazyListState, marketState: SwapMarketState.Content) {
val visibleItems by remember {
derivedStateOf {
lazyListState.layoutInfo.visibleItemsInfo
.mapNotNull { itemInfo ->
marketState.items.find { it.getComposeKey() == itemInfo.key }?.id
}
}
}
LaunchedEffect(visibleItems) {
marketState.visibleIdsChanged(visibleItems)
}
}
@ -387,50 +442,45 @@ private fun TokenItem(
}
}
private val token = TokenToSelectState.TokenToSelect(
tokenIcon = CurrencyIconState.CoinIcon(
url = "",
fallbackResId = 0,
isGrayscale = false,
shouldShowCustomBadge = false,
),
id = "",
name = "Optimistic Ethereum (ETH)",
symbol = "USDC",
addedTokenBalanceData = TokenBalanceData(
amount = "15 000 $",
amountEquivalent = "15 000 USDT",
isBalanceHidden = false,
),
)
private val title = TokenToSelectState.Title(
title = stringReference("MY TOKENS"),
)
@Preview
@Composable
private fun TokenScreenPreview() {
TangemThemePreview {
SwapSelectTokenScreen(
state = SwapSelectTokenStateHolder(
availableTokens = listOf(title, token, token, token).toImmutableList(),
unavailableTokens = listOf(title, token, token, token).toImmutableList(),
tokensListData = TokenListUMData.EmptyList,
isAfterSearch = false,
isBalanceHidden = false,
onSearchEntered = {},
onTokenSelected = {},
),
onBack = {},
)
}
private class SwapSelectTokenScreenPreviewProvider : PreviewParameterProvider<SwapSelectTokenStateHolder> {
override val values: Sequence<SwapSelectTokenStateHolder> = sequenceOf(
// Content state with tokens and markets
SwapSelectTokenPreviewProvider().provideSwapSelectTokenState(),
// Empty state
SwapSelectTokenStateHolder(
availableTokens = persistentListOf(),
unavailableTokens = persistentListOf(),
tokensListData = TokenListUMData.EmptyList,
marketsState = null,
isAfterSearch = false,
isBalanceHidden = false,
onSearchEntered = {},
onTokenSelected = {},
),
// Not found state
SwapSelectTokenStateHolder(
availableTokens = persistentListOf(),
unavailableTokens = persistentListOf(),
tokensListData = TokenListUMData.EmptyList,
marketsState = null,
isAfterSearch = true,
isBalanceHidden = false,
onSearchEntered = {},
onTokenSelected = {},
),
)
}
@Preview
@Composable
private fun EmptyTokensListPreview() {
private fun TokenScreenPreview(
@PreviewParameter(SwapSelectTokenScreenPreviewProvider::class)
state: SwapSelectTokenStateHolder,
) {
TangemThemePreview {
EmptyTokensList()
SwapSelectTokenScreen(
state = state,
onBack = {},
)
}
}

View file

@ -0,0 +1,94 @@
package com.tangem.feature.swap.ui.market
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import com.tangem.common.ui.markets.MarketsListItem
import com.tangem.common.ui.markets.MarketsListItemPlaceholder
import com.tangem.core.ui.components.UnableToLoadData
import com.tangem.core.ui.decorations.roundedShapeItemDecoration
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.feature.swap.models.market.state.SwapMarketState
import com.tangem.feature.swap.presentation.R
internal fun LazyListScope.swapMarketsListItems(state: SwapMarketState) {
item {
Text(
text = stringResourceSafe(R.string.markets_common_title),
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
modifier = Modifier.fillMaxWidth().padding(horizontal = TangemTheme.dimens.spacing16),
)
}
when (state) {
is SwapMarketState.Loading -> {
items(count = 100, key = { "market_placeholder_$it" }) {
MarketsListItemPlaceholder()
}
}
is SwapMarketState.LoadingError -> {
item(key = "market_loading_error") {
LoadingErrorItem(
modifier = Modifier.fillParentMaxWidth(),
onTryAgain = state.onRetryClicked,
)
}
}
SwapMarketState.SearchNothingFound -> {
item(key = "market_not_found") {
SearchNothingFoundText(
modifier = Modifier.fillParentMaxWidth(),
)
}
}
is SwapMarketState.Content -> {
itemsIndexed(
items = state.items,
key = { _, item -> item.getComposeKey() },
) { index, item ->
MarketsListItem(
model = item,
onClick = { state.onItemClick(item) },
modifier = Modifier.roundedShapeItemDecoration(
currentIndex = index,
lastIndex = state.items.lastIndex,
backgroundColor = TangemTheme.colors.background.action,
),
)
}
}
}
}
@Composable
private fun LoadingErrorItem(onTryAgain: () -> Unit, modifier: Modifier = Modifier) {
Box(
modifier
.padding(
horizontal = TangemTheme.dimens.spacing16,
vertical = TangemTheme.dimens.spacing12,
),
contentAlignment = Alignment.Center,
) {
UnableToLoadData(onRetryClick = onTryAgain)
}
}
@Composable
private fun SearchNothingFoundText(modifier: Modifier = Modifier) {
Box(
modifier = modifier.padding(TangemTheme.dimens.spacing16),
contentAlignment = Alignment.Center,
) {
Text(
text = stringResourceSafe(R.string.markets_search_token_no_result_title),
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.text.tertiary,
)
}
}

View file

@ -0,0 +1,155 @@
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.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.feature.swap.models.SwapSelectTokenStateHolder
import com.tangem.feature.swap.models.TokenBalanceData
import com.tangem.feature.swap.models.TokenListUMData
import com.tangem.feature.swap.models.TokenToSelectState
import com.tangem.feature.swap.models.market.state.SwapMarketState
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.persistentListOf
internal class SwapSelectTokenPreviewProvider {
fun provideSwapSelectTokenState(): SwapSelectTokenStateHolder {
return SwapSelectTokenStateHolder(
availableTokens = listOf(previewTitle, previewToken, previewToken, previewToken).toImmutableList(),
unavailableTokens = listOf(previewTitle, previewToken, previewToken, previewToken).toImmutableList(),
tokensListData = TokenListUMData.EmptyList,
isAfterSearch = false,
isBalanceHidden = false,
onSearchEntered = {},
onTokenSelected = {},
marketsState = createPreviewMarketsState(),
)
}
private fun createPreviewMarketsState() = SwapMarketState.Content(
items = createPreviewMarketItems(),
loadMore = { },
onItemClick = { },
visibleIdsChanged = { },
)
private fun createPreviewMarketItems() = listOf(
createMarketItem(
id = "1",
iconUrl = "",
ratingPosition = "10",
marketCap = "$6.233 B",
trendType = PriceChangeType.UP,
chartData = PREVIEW_CHART_DATA,
),
createMarketItem(
id = "2",
ratingPosition = "10",
marketCap = "$6.233 B",
trendType = PriceChangeType.NEUTRAL,
chartData = null,
),
createMarketItem(
id = "3",
name = "Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin",
ratingPosition = "10",
marketCap = "$6.23348172384781234 B",
trendType = PriceChangeType.DOWN,
chartData = PREVIEW_CHART_DATA,
),
createMarketItem(
id = "4",
ratingPosition = "10",
marketCap = null,
trendType = PriceChangeType.UP,
chartData = PREVIEW_CHART_DATA,
),
createMarketItem(
id = "5",
ratingPosition = null,
marketCap = "$6.233 B",
trendType = PriceChangeType.UP,
chartData = PREVIEW_CHART_DATA,
),
createMarketItem(
id = "6",
ratingPosition = null,
marketCap = null,
trendType = PriceChangeType.UP,
chartData = PREVIEW_CHART_DATA,
),
).toImmutableList()
private fun createMarketItem(
id: String,
name: String = "Bitcoin",
iconUrl: String? = null,
ratingPosition: String?,
marketCap: String?,
trendType: PriceChangeType,
chartData: MarketChartRawData?,
) = MarketsListItemUM(
id = CryptoCurrency.RawID(id),
name = name,
currencySymbol = "BTC",
iconUrl = iconUrl,
ratingPosition = ratingPosition,
marketCap = marketCap,
price = MarketsListItemUM.Price(text = "31 285.72$"),
trendPercentText = "12.43%",
trendType = trendType,
chartData = chartData,
isUnder100kMarketCap = false,
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 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 previewToken = TokenToSelectState.TokenToSelect(
tokenIcon = CurrencyIconState.CoinIcon(
url = "",
fallbackResId = 0,
isGrayscale = false,
shouldShowCustomBadge = false,
),
id = "",
name = "Optimistic Ethereum (ETH)",
symbol = "USDC",
addedTokenBalanceData = TokenBalanceData(
amount = "15 000 $",
amountEquivalent = "15 000 USDT",
isBalanceHidden = false,
),
)
private val previewTitle = TokenToSelectState.Title(
title = stringReference("MY TOKENS"),
)
}
}