Updated on 2026-08-14

This commit is contained in:
Tangem 2026-01-20 15:38:36 +03:00
parent 73ca4410e1
commit ab22a6c131
13 changed files with 191 additions and 67 deletions

View file

@ -14,11 +14,13 @@ sealed class BatchFetchResult<out TData> {
* @param data fetched data.
* @param empty indicates that data is empty and [BatchListSource] shouldn't create new batch for this result
* @param last indicates if this is the last batch for the request.
* @param total total number of items available on the server.
*/
data class Success<TData>(
val data: TData,
val empty: Boolean,
val last: Boolean,
val total: Int? = null,
) : BatchFetchResult<TData>()
/**

View file

@ -69,7 +69,7 @@ private class DefaultBatchListSource<TKey, TData, TRequestParams : Any, TUpdate>
private val updateFetcher: BatchUpdateFetcher<TKey, TData, TUpdate>? = null,
) : BatchListSource<TKey, TData, TUpdate> {
override val state = MutableStateFlow(BatchListState<TKey, TData>(emptyList(), PaginationStatus.None))
override val state = MutableStateFlow(BatchListState<TKey, TData>(emptyList(), PaginationStatus.None, null))
override val updateResults = MutableSharedFlow<Pair<TUpdate, BatchUpdateResult<TKey, TData>>>(
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
@ -254,6 +254,7 @@ private class DefaultBatchListSource<TKey, TData, TRequestParams : Any, TUpdate>
} else {
PaginationStatus.Paginating(res)
},
totalCount = res.total,
)
}
is BatchFetchResult.Error -> {
@ -262,6 +263,7 @@ private class DefaultBatchListSource<TKey, TData, TRequestParams : Any, TUpdate>
status = PaginationStatus.InitialLoadingError(
throwable = res.throwable,
),
totalCount = null,
)
}
}

View file

@ -8,10 +8,12 @@ package com.tangem.pagination
*
* @property data list of loaded batches.
* @property status current status of the pagination.
* @property totalCount total number of items available on the server.
*
* @see BatchListSource
*/
data class BatchListState<TKey, TData>(
val data: List<Batch<TKey, TData>>,
val status: PaginationStatus<TData>,
val totalCount: Int? = null,
)

View file

@ -98,6 +98,7 @@ internal class DefaultMarketsTokenRepository(
data = tokenMarketListWithMaxApy.tokens,
last = isLast,
empty = res.tokens.isEmpty(),
total = res.total,
)
}
},

View file

@ -48,22 +48,28 @@ internal class UpdateAccountTokenListTransformer(
tokensList = accountListItemConverter.convertList(accountList).toPersistentList(),
)
} else {
TokenListUMData.TokenList(
tokensList = persistentListOf(
TokensListItemUM.GroupTitle(
id = "available_tokens_title",
text = resourceReference(R.string.exchange_tokens_available_tokens_header),
),
) + accountList.flatMap { (_, currencyList) ->
currencyList.asSequence().map { (isAvailable, status) ->
if (isAvailable) {
availableConverter.convert(status)
} else {
unavailableConverter.convert(status)
}
}.map(TokensListItemUM::Token).toPersistentList()
}.toPersistentList(),
)
val tokensList = accountList.flatMap { (_, currencyList) ->
currencyList.asSequence().map { (isAvailable, status) ->
if (isAvailable) {
availableConverter.convert(status)
} else {
unavailableConverter.convert(status)
}
}.map(TokensListItemUM::Token).toPersistentList()
}.toPersistentList()
if (tokensList.isNotEmpty()) {
TokenListUMData.TokenList(
tokensList = persistentListOf(
TokensListItemUM.GroupTitle(
id = "available_tokens_title",
text = resourceReference(R.string.exchange_tokens_available_tokens_header),
),
) + tokensList,
)
} else {
TokenListUMData.EmptyList
}
},
isBalanceHidden = isBalanceHidden,
warning = warning,

View file

@ -37,26 +37,36 @@ internal class TokensDataConverterV2(
availableTokens = persistentListOf(),
unavailableTokens = persistentListOf(),
tokensListData = if (isAccountsMode) {
val portfolioList = accountListItemConverter.convertList(accountList).toPersistentList()
val totalTokensCount = portfolioList.sumOf { it.tokens.size }
TokenListUMData.AccountList(
tokensList = accountListItemConverter.convertList(accountList).toPersistentList(),
tokensList = portfolioList,
totalTokensCount = totalTokensCount,
)
} else {
TokenListUMData.TokenList(
tokensList = persistentListOf(
TokensListItemUM.GroupTitle(
id = "available_tokens_title",
text = resourceReference(R.string.exchange_tokens_available_tokens_header),
),
) + accountList.flatMap { (_, currencyList) ->
currencyList.asSequence().map { accountSwapCurrency ->
if (accountSwapCurrency.isAvailable) {
accountListItemConverter.createAvailableItemConverter()
} else {
accountListItemConverter.createUnavailableItemConverter()
}.convert(accountSwapCurrency.cryptoCurrencyStatus)
}.map(TokensListItemUM::Token).toPersistentList()
}.toPersistentList(),
)
val tokensList = accountList.flatMap { (_, currencyList) ->
currencyList.asSequence().map { accountSwapCurrency ->
if (accountSwapCurrency.isAvailable) {
accountListItemConverter.createAvailableItemConverter()
} else {
accountListItemConverter.createUnavailableItemConverter()
}.convert(accountSwapCurrency.cryptoCurrencyStatus)
}.map(TokensListItemUM::Token).toPersistentList()
}.toPersistentList()
if (tokensList.isNotEmpty()) {
TokenListUMData.TokenList(
tokensList = persistentListOf(
TokensListItemUM.GroupTitle(
id = "available_tokens_title",
text = resourceReference(R.string.exchange_tokens_available_tokens_header),
),
) + tokensList,
totalTokensCount = tokensList.size,
)
} else {
TokenListUMData.EmptyList
}
},
marketsState = currentMarketsState,
onSearchEntered = onSearchEntered,

View file

@ -261,6 +261,13 @@ internal class SwapModel @Inject constructor(
override fun onSuccess(addedToken: CryptoCurrency) {
modelScope.launch {
bottomSheetNavigation.dismiss()
uiState.selectTokenState?.let { currentSelectState ->
uiState = uiState.copy(
selectTokenState = currentSelectState.copy(
marketsState = null,
),
)
}
getAccountCurrencyStatusUseCase.invoke(userWalletId, addedToken)
.firstOrNull {
it.status.value is CryptoCurrencyStatus.Loaded
@ -341,7 +348,8 @@ internal class SwapModel @Inject constructor(
flow2 = searchMarketsListManager.uiItems,
flow3 = searchMarketsListManager.isInInitialLoadingErrorState,
flow4 = searchMarketsListManager.isSearchNotFoundState,
) { searchQuery, uiItems, isError, isSearchNotFound ->
flow5 = searchMarketsListManager.totalCount.filterNotNull(),
) { searchQuery, uiItems, isError, isSearchNotFound, total ->
when {
searchQuery.isEmpty() -> {
visibleMarketItemIds.value = emptyList()
@ -359,6 +367,7 @@ internal class SwapModel @Inject constructor(
addToPortfolioItem(item)
},
visibleIdsChanged = { visibleMarketItemIds.value = it },
total = total,
)
}
}

View file

@ -41,17 +41,25 @@ internal data class TokenBalanceData(
internal sealed interface TokenListUMData {
val tokensList: ImmutableList<TokensListItemUM>
val totalTokensCount: Int
data class AccountList(
override val tokensList: ImmutableList<TokensListItemUM.Portfolio>,
override val totalTokensCount: Int,
) : TokenListUMData
data class TokenList(
override val tokensList: ImmutableList<TokensListItemUM>,
override val totalTokensCount: Int,
) : TokenListUMData
data object EmptyList : TokenListUMData {
override val tokensList: ImmutableList<TokensListItemUM> = persistentListOf()
override val totalTokensCount: Int = EMPTY_TOKENS_COUNT
}
private companion object {
const val EMPTY_TOKENS_COUNT = 0
}
}

View file

@ -78,6 +78,15 @@ internal class SwapMarketsListBatchFlowManager(
initialValue = false,
)
val totalCount: StateFlow<Int?> = batchFlow.state
.map { it.totalCount }
.distinctUntilChanged()
.stateIn(
scope = modelScope,
started = SharingStarted.Eagerly,
initialValue = null,
)
init {
batchFlow.state
.map { it.data }

View file

@ -10,6 +10,7 @@ internal sealed class SwapMarketState {
data class Content(
val items: ImmutableList<MarketsListItemUM>,
val total: Int,
val loadMore: () -> Unit,
val onItemClick: (MarketsListItemUM) -> Unit,
val visibleIdsChanged: (List<CryptoCurrency.RawID>) -> Unit,

View file

@ -23,8 +23,11 @@ import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
@ -73,6 +76,11 @@ internal fun SwapSelectTokenScreen(state: SwapSelectTokenStateHolder, onBack: ()
when {
state.isNotFoundState -> TokensNotFound(modifier)
state.isEmptyState -> EmptyTokensList(modifier)
state.marketsState != null -> ListOfTokensWithMarkets(
state = state,
marketsState = state.marketsState,
modifier = modifier,
)
else -> ListOfTokens(state = state, modifier = modifier)
}
},
@ -142,6 +150,34 @@ private fun TokensNotFound(modifier: Modifier = Modifier) {
@Composable
private fun ListOfTokens(state: SwapSelectTokenStateHolder, modifier: Modifier = Modifier) {
val screenBackgroundColor = TangemTheme.colors.background.secondary
LazyColumn(
modifier = modifier
.background(color = screenBackgroundColor)
.fillMaxSize()
.imePadding(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
tokensListItems(
tokensListData = state.tokensListData,
isBalanceHidden = state.isBalanceHidden,
)
tokensToSelectItems(state.availableTokens, state.onTokenSelected)
item { SpacerH12() }
tokensToSelectItems(state.unavailableTokens, state.onTokenSelected)
}
}
@Composable
private fun ListOfTokensWithMarkets(
state: SwapSelectTokenStateHolder,
marketsState: SwapMarketState,
modifier: Modifier = Modifier,
) {
val screenBackgroundColor = TangemTheme.colors.background.secondary
val lazyListState = rememberLazyListState()
LazyColumn(
@ -152,54 +188,37 @@ private fun ListOfTokens(state: SwapSelectTokenStateHolder, modifier: Modifier =
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.TokenList -> {
tokensList(
items = list.tokensList,
isBalanceHidden = state.isBalanceHidden,
)
}
TokenListUMData.EmptyList -> Unit
if (state.tokensListData !is TokenListUMData.EmptyList) {
assetsTitle(count = state.tokensListData.totalTokensCount)
}
if (state.availableTokens.isNotEmpty()) {
tokensToSelectItems(state.availableTokens, state.onTokenSelected)
}
if (state.unavailableTokens.isNotEmpty()) {
tokensListItems(
tokensListData = state.tokensListData,
isBalanceHidden = state.isBalanceHidden,
)
if (state.tokensListData is TokenListUMData.EmptyList) {
item { SpacerH12() }
tokensToSelectItems(state.unavailableTokens, state.onTokenSelected)
}
if (state.availableTokens.isNotEmpty() || state.unavailableTokens.isNotEmpty()) {
item { SpacerH32() }
} else {
item { SpacerH12() }
item { SpacerH32() }
}
state.marketsState?.let(::swapMarketsListItems)
swapMarketsListItems(marketsState)
}
(state.marketsState as? SwapMarketState.Content)?.let { marketState ->
(marketsState as? SwapMarketState.Content)?.let { content ->
VisibleItemsTracker(
lazyListState = lazyListState,
marketState = marketState,
marketState = content,
)
InfiniteListHandler(
listState = lazyListState,
buffer = LOAD_MORE_BUFFER,
triggerLoadMoreCheckOnItemsCountChange = true,
onLoadMore = remember(state) {
onLoadMore = remember(content) {
{
state.marketsState.loadMore()
content.loadMore()
true
}
},
@ -223,6 +242,48 @@ private fun VisibleItemsTracker(lazyListState: LazyListState, marketState: SwapM
}
}
private fun LazyListScope.assetsTitle(count: Int) {
item(key = "assets_title") {
Text(
text = buildAnnotatedString {
append(stringResourceSafe(R.string.swap_your_assets_title))
withStyle(SpanStyle(color = TangemTheme.colors.text.tertiary)) {
append(" $count")
}
},
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
modifier = Modifier
.fillMaxWidth()
.padding(
start = TangemTheme.dimens.spacing16,
end = TangemTheme.dimens.spacing16,
top = TangemTheme.dimens.spacing12,
),
)
}
}
private fun LazyListScope.tokensListItems(tokensListData: TokenListUMData, isBalanceHidden: Boolean) {
when (tokensListData) {
is TokenListUMData.AccountList -> {
tokensListData.tokensList.forEach { item ->
portfolioTokensList(
portfolio = item,
isBalanceHidden = isBalanceHidden,
)
}
}
is TokenListUMData.TokenList -> {
tokensList(
items = tokensListData.tokensList,
isBalanceHidden = isBalanceHidden,
)
}
TokenListUMData.EmptyList -> Unit
}
}
private fun LazyListScope.tokensList(items: ImmutableList<TokensListItemUM>, isBalanceHidden: Boolean) {
itemsIndexed(
items = items,

View file

@ -7,6 +7,9 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import com.tangem.common.ui.markets.MarketsListItem
import com.tangem.common.ui.markets.MarketsListItemPlaceholder
import com.tangem.core.ui.components.UnableToLoadData
@ -18,8 +21,16 @@ import com.tangem.feature.swap.presentation.R
internal fun LazyListScope.swapMarketsListItems(state: SwapMarketState) {
item {
val totalCount = (state as? SwapMarketState.Content)?.total
Text(
text = stringResourceSafe(R.string.markets_common_title),
text = buildAnnotatedString {
append(stringResourceSafe(R.string.markets_common_title))
if (totalCount != null) {
withStyle(SpanStyle(color = TangemTheme.colors.text.tertiary)) {
append(" $totalCount")
}
}
},
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
modifier = Modifier.fillMaxWidth().padding(horizontal = TangemTheme.dimens.spacing16),

View file

@ -34,6 +34,7 @@ internal class SwapSelectTokenPreviewProvider {
loadMore = { },
onItemClick = { },
visibleIdsChanged = { },
total = TOTAL_ITEMS,
)
private fun createPreviewMarketItems() = listOf(
@ -114,6 +115,7 @@ internal class SwapSelectTokenPreviewProvider {
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(