From 06d1f35002520665f8ebb2387b366ace33c00f2b Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 9 Feb 2026 17:12:27 +0300 Subject: [PATCH] Updated on 2026-08-14 --- features/onramp/impl/build.gradle.kts | 4 + .../DefaultAvailableSwapPairsComponent.kt | 4 +- .../SetNoAvailablePairsTransformerV2.kt | 4 + .../market/SwapMarketsListBatchFlowManager.kt | 243 ++++++++++++++++++ .../SwapMarketsTokenItemConverter.kt | 148 +++++++++++ .../market/state/SwapMarketState.kt | 26 ++ .../model/AvailableSwapPairsModel.kt | 85 ++++++ .../availablepairs/ui/SwapMarketsListItems.kt | 113 ++++++++ .../onramp/swap/ui/SwapSelectTokens.kt | 185 +++++++++---- .../onramp/tokenlist/entity/TokenListUM.kt | 12 +- .../SetLoadingAccountTokenListTransformer.kt | 8 + .../SetNothingToFoundStateTransformerV2.kt | 1 + .../UpdateAccountTokenListTransformer.kt | 4 + .../onramp/tokenlist/ui/OnrampTokenList.kt | 96 ++++++- 14 files changed, 877 insertions(+), 56 deletions(-) create mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/market/SwapMarketsListBatchFlowManager.kt create mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/market/converter/SwapMarketsTokenItemConverter.kt create mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/market/state/SwapMarketState.kt create mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/ui/SwapMarketsListItems.kt diff --git a/features/onramp/impl/build.gradle.kts b/features/onramp/impl/build.gradle.kts index 1e3239f200..d836fce188 100644 --- a/features/onramp/impl/build.gradle.kts +++ b/features/onramp/impl/build.gradle.kts @@ -15,6 +15,7 @@ dependencies { /** Project - API */ implementation(projects.features.account.api) implementation(projects.features.onramp.api) + implementation(projects.features.swap.api) implementation(projects.features.swap.domain) implementation(projects.features.swap.domain.api) implementation(projects.features.swap.domain.models) @@ -30,6 +31,8 @@ dependencies { /** Project - Common */ implementation(projects.common.routing) implementation(projects.common.ui) + implementation(projects.common.uiMarkets) + implementation(projects.common.uiCharts) /** Project - Domain */ implementation(projects.domain.appCurrency) @@ -51,6 +54,7 @@ dependencies { implementation(projects.domain.appTheme) implementation(projects.domain.appTheme.models) implementation(projects.data.common) + implementation(projects.domain.markets) /** DI */ implementation(deps.hilt.android) diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/DefaultAvailableSwapPairsComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/DefaultAvailableSwapPairsComponent.kt index eea5740b23..456b6e8c30 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/DefaultAvailableSwapPairsComponent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/DefaultAvailableSwapPairsComponent.kt @@ -7,7 +7,7 @@ import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.features.onramp.swap.availablepairs.model.AvailableSwapPairsModel import com.tangem.features.onramp.tokenlist.entity.TokenListUM -import com.tangem.features.onramp.tokenlist.ui.onrampTokenList +import com.tangem.features.onramp.tokenlist.ui.onrampSwapTokenList import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -25,7 +25,7 @@ internal class DefaultAvailableSwapPairsComponent @AssistedInject constructor( get() = model.state override fun LazyListScope.content(uiState: TokenListUM, modifier: Modifier) { - onrampTokenList(state = uiState) + onrampSwapTokenList(state = uiState) } @AssistedFactory diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/entity/transformers/SetNoAvailablePairsTransformerV2.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/entity/transformers/SetNoAvailablePairsTransformerV2.kt index 9ee61d4ba9..ac87e348b8 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/entity/transformers/SetNoAvailablePairsTransformerV2.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/entity/transformers/SetNoAvailablePairsTransformerV2.kt @@ -26,6 +26,8 @@ internal class SetNoAvailablePairsTransformerV2( .createUnavailableItemConverterV2(appCurrency = appCurrency, unavailableErrorText = unavailableErrorText) override fun transform(prevState: TokenListUM): TokenListUM { + val totalTokensCount = accountList.values.sumOf { it.size } + return prevState.copy( availableItems = persistentListOf(), unavailableItems = persistentListOf(), @@ -45,6 +47,7 @@ internal class SetNoAvailablePairsTransformerV2( .toPersistentList(), ) }.toPersistentList(), + totalTokensCount = totalTokensCount, ) } else { TokenListUMData.TokenList( @@ -52,6 +55,7 @@ internal class SetNoAvailablePairsTransformerV2( unavailableConverter.convertList(cryptoCurrencies) .map(TokensListItemUM::Token) }.toPersistentList(), + totalTokensCount = totalTokensCount, ) }, isBalanceHidden = isBalanceHidden, diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/market/SwapMarketsListBatchFlowManager.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/market/SwapMarketsListBatchFlowManager.kt new file mode 100644 index 0000000000..e25d1bcc4a --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/market/SwapMarketsListBatchFlowManager.kt @@ -0,0 +1,243 @@ +package com.tangem.features.onramp.swap.availablepairs.market + +import com.tangem.common.ui.markets.models.MarketsListItemUM +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.* +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.features.onramp.swap.availablepairs.market.converter.SwapMarketsTokenItemConverter +import com.tangem.pagination.Batch +import com.tangem.pagination.BatchAction +import com.tangem.pagination.PaginationStatus +import com.tangem.utils.Provider +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.coroutines.JobHolder +import com.tangem.utils.coroutines.saveIn +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* + +@Suppress("LongParameterList") +internal class SwapMarketsListBatchFlowManager( + getMarketsTokenListFlowUseCase: GetMarketsTokenListFlowUseCase, + private val batchFlowType: GetMarketsTokenListFlowUseCase.BatchFlowType, + private val currentAppCurrency: Provider, + private val currentSearchText: Provider, + private val modelScope: CoroutineScope, + private val dispatchers: CoroutineDispatcherProvider, +) { + private val actionsFlow = MutableSharedFlow>() + private val updateStateJob = JobHolder() + + private val batchFlow = getMarketsTokenListFlowUseCase( + batchingContext = TokenListBatchingContext( + actionsFlow = actionsFlow, + coroutineScope = modelScope, + ), + batchFlowType = batchFlowType, + ) + + private val resultBatches = MutableStateFlow(ResultBatches()) + private val uiBatches = resultBatches.map { it.uiBatches } + + val uiItems: StateFlow> + get() = uiBatches + .map { batches -> + batches.asSequence() + .map { it.data } + .flatten() + .toImmutableList() + } + .distinctUntilChanged() + .stateIn( + scope = modelScope, + started = SharingStarted.Eagerly, + initialValue = persistentListOf(), + ) + + val isInInitialLoadingErrorState = batchFlow.state + .map { it.status is PaginationStatus.InitialLoadingError } + .distinctUntilChanged() + .stateIn( + scope = modelScope, + started = SharingStarted.Eagerly, + initialValue = false, + ) + + val isSearchNotFoundState = batchFlow.state + .map { batchListState -> + currentSearchText().isNullOrEmpty().not() && + batchListState.status is PaginationStatus.EndOfPagination && + batchListState.data.isEmpty() + } + .distinctUntilChanged() + .stateIn( + scope = modelScope, + started = SharingStarted.Eagerly, + initialValue = false, + ) + + val totalCount: StateFlow = batchFlow.state + .map { it.totalCount } + .distinctUntilChanged() + .stateIn( + scope = modelScope, + started = SharingStarted.Eagerly, + initialValue = null, + ) + + init { + batchFlow.state + .map { it.data } + .distinctUntilChanged { a, b -> + a.size == b.size && + a.map { it.key } == b.map { it.key } && + a.map { it.data }.flatten() == b.map { it.data }.flatten() + } + .onEach { + coroutineScope { + launch { + updateState(it) + }.saveIn(updateStateJob) + } + } + .flowOn(dispatchers.default) + .launchIn(modelScope) + } + + private suspend fun updateState(newList: List>>, forceUpdate: Boolean = false) = + withContext(dispatchers.default) { + resultBatches.update { resultBatches -> + val items = resultBatches.uiBatches + val previousList = resultBatches.processedItems + + val converter = SwapMarketsTokenItemConverter(appCurrency = currentAppCurrency()) + + if (newList.isEmpty()) { + return@update ResultBatches(processedItems = emptyList()) + } + + val isInitialLoading = + forceUpdate || previousList.isNullOrEmpty() || newList.first().key != previousList.first().key + + val outItems = if (isInitialLoading) { + newList.map { batch -> + Batch( + key = batch.key, + data = converter.convertList(batch.data), + ) + } + } else { + if (previousList.size != newList.size) { + val keysToAdd = newList.map { it.key }.subtract(previousList.map { it.key }.toSet()) + val newBatches = newList.filter { keysToAdd.contains(it.key) } + + items + newBatches.map { batch -> + Batch( + key = batch.key, + data = converter.convertList(batch.data), + ) + } + } else { + items.mapIndexed { batchIndex, batch -> + val prevBatch = previousList[batchIndex] + val newBatch = newList[batchIndex] + if (prevBatch == newBatch) return@mapIndexed batch + + Batch( + key = batch.key, + data = batch.data.mapIndexed { index, marketsListItemUM -> + val prevItem = prevBatch.data.getOrNull(index) + val newItem = newBatch.data.getOrNull(index) + if (prevItem != null && newItem != null) { + converter.update(prevItem, marketsListItemUM, newItem) + } else { + newItem?.let { converter.convert(it) } ?: marketsListItemUM + } + }, + ) + } + } + } + + currentCoroutineContext().ensureActive() + + ResultBatches( + uiBatches = outItems, + processedItems = newList, + ) + } + } + + fun reload(searchText: String? = null) { + modelScope.launch { + resultBatches.value = ResultBatches() + actionsFlow.emit( + BatchAction.Reload( + requestParams = TokenMarketListConfig( + fiatPriceCurrency = currentAppCurrency().code, + searchText = if (currentSearchText() == null) { + null + } else { + searchText ?: currentSearchText() + }, + priceChangeInterval = TokenMarketListConfig.Interval.H24, + order = TokenMarketListConfig.Order.ByRating, + ), + ), + ) + } + } + + fun loadMore() { + modelScope.launch { + actionsFlow.emit(BatchAction.LoadMore()) + } + } + + fun loadCharts(batchKeys: Set) { + if (batchKeys.isEmpty()) return + + modelScope.launch { + val currentData = batchFlow.state.value.data + val alreadyLoadedChartsBatchKeys = currentData + .filter { batch -> + val first = batch.data.firstOrNull() ?: return@filter false + first.tokenCharts.h24 != null + } + .map { it.key } + .toSet() + + val batchesKeysToLoad = batchKeys.minus(alreadyLoadedChartsBatchKeys) + + if (batchesKeysToLoad.isNotEmpty()) { + actionsFlow.emit( + BatchAction.UpdateBatches( + keys = batchesKeysToLoad, + updateRequest = TokenMarketUpdateRequest.UpdateChart( + interval = TokenMarketListConfig.Interval.H24, + currency = currentAppCurrency().code, + ), + async = true, + operationId = batchesKeysToLoad.toString() + "h24", + ), + ) + } + } + } + + fun getBatchKeysByItemIds(ids: List): Set { + val currentData = batchFlow.state.value.data + + return currentData + .filter { d -> d.data.any { ids.contains(it.id) } } + .map { it.key } + .toSet() + } + + private data class ResultBatches( + val uiBatches: List>> = emptyList(), + val processedItems: List>>? = null, + ) +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/market/converter/SwapMarketsTokenItemConverter.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/market/converter/SwapMarketsTokenItemConverter.kt new file mode 100644 index 0000000000..6ea8ec9f3c --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/market/converter/SwapMarketsTokenItemConverter.kt @@ -0,0 +1,148 @@ +package com.tangem.features.onramp.swap.availablepairs.market.converter + +import com.tangem.common.ui.charts.state.MarketChartData +import com.tangem.common.ui.charts.state.MarketChartRawData +import com.tangem.common.ui.charts.state.converter.PriceAndTimePointValuesConverter +import com.tangem.common.ui.charts.state.sorted +import com.tangem.common.ui.markets.models.MarketsListItemUM +import com.tangem.core.ui.R +import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.format.bigdecimal.compact +import com.tangem.core.ui.format.bigdecimal.fiat +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.format.bigdecimal.percent +import com.tangem.core.ui.format.bigdecimal.price +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.TokenMarket +import com.tangem.utils.converter.Converter +import kotlinx.collections.immutable.toImmutableList +import java.math.BigDecimal +import java.math.RoundingMode + +internal class SwapMarketsTokenItemConverter( + private val appCurrency: AppCurrency, +) : Converter { + + private val priceAndTimePointValuesConverter = PriceAndTimePointValuesConverter(shouldFormatAxis = false) + + override fun convert(value: TokenMarket): MarketsListItemUM { + return MarketsListItemUM( + id = value.id, + name = value.name, + currencySymbol = value.symbol, + ratingPosition = value.marketRating?.toString(), + marketCap = value.getMarketCap(), + iconUrl = value.imageUrlLarge, + price = value.getCurrentPrice(), + trendPercentText = value.getTrendPercent(), + trendType = value.getTrendType(), + chartData = value.getChartData(), + isUnder100kMarketCap = value.isUnderMarketCapLimit, + stakingRate = value.yieldRate?.format { percent() }?.let { + resourceReference(R.string.markets_apy_placeholder, wrappedList(it)) + }, + updateTimestamp = value.updateTimestamp, + ) + } + + fun convertList(items: List): List = items.map(::convert) + + fun update(prev: TokenMarket, prevUI: MarketsListItemUM, new: TokenMarket): MarketsListItemUM { + require(prev.id == new.id) { + "Ids is not the same during update TokenMarket item: previousItem[${prev.id}] != newItem[${new.id}]" + } + + return prevUI.copy( + name = new.name, + currencySymbol = new.symbol, + ratingPosition = new.marketRating?.toString(), + marketCap = ifChanged(prev.marketCap, new.marketCap, prevUI.marketCap) { new.getMarketCap() }, + iconUrl = new.imageUrlLarge, + price = ifChanged(prev = prev.tokenQuotesShort, new = new.tokenQuotesShort, prevR = prevUI.price) { + new.getCurrentPrice(prev = prev) + }, + trendPercentText = ifChanged( + prev.tokenQuotesShort, + new.tokenQuotesShort, + prevUI.trendPercentText, + ) { new.getTrendPercent() }, + trendType = ifChanged(prev.tokenQuotesShort, new.tokenQuotesShort, prevUI.trendType) { new.getTrendType() }, + chartData = ifChanged(prev.tokenCharts, new.tokenCharts, prevUI.chartData) { new.getChartData() }, + ) + } + + private inline fun ifChanged(prev: T, new: T, prevR: R, force: Boolean = false, change: (T) -> R): R { + return if (force || prev != new) change(new) else prevR + } + + private fun TokenMarket.getMarketCap(): String? { + val value = marketCap?.takeIf { marketCap != BigDecimal.ZERO } ?: return null + + return value.format { + fiat( + fiatCurrencyCode = appCurrency.code, + fiatCurrencySymbol = appCurrency.symbol, + ).compact( + threeDigitsMethod = true, + ) + } + } + + private fun TokenMarket.getCurrentPrice(prev: TokenMarket? = null): MarketsListItemUM.Price { + val prevPrice = prev?.tokenQuotesShort?.currentPrice + + val priceText = tokenQuotesShort.currentPrice.format { + fiat( + fiatCurrencyCode = appCurrency.code, + fiatCurrencySymbol = appCurrency.symbol, + ).price() + } + + val changeType = if (prevPrice != null) { + if (tokenQuotesShort.currentPrice > prevPrice) { + PriceChangeType.UP + } else { + PriceChangeType.DOWN + } + } else { + null + } + + return MarketsListItemUM.Price( + text = priceText, + changeType = changeType, + ) + } + + private fun TokenMarket.getChartData(): MarketChartRawData? { + val chart = tokenCharts.h24 + + return chart?.let { ct -> + priceAndTimePointValuesConverter.convert( + MarketChartData.Data( + y = ct.priceY.toImmutableList(), + x = ct.timeStamps.map { it.toBigDecimal() }.toImmutableList(), + ).sorted(), + ) + } + } + + @Suppress("MagicNumber") + private fun TokenMarket.getTrendType(): PriceChangeType { + val percent = tokenQuotesShort.h24ChangePercent + val scaled = percent?.setScale(4, RoundingMode.HALF_UP) + return when { + scaled == null -> PriceChangeType.NEUTRAL + scaled > BigDecimal.ZERO -> PriceChangeType.UP + scaled < BigDecimal.ZERO -> PriceChangeType.DOWN + else -> PriceChangeType.NEUTRAL + } + } + + private fun TokenMarket.getTrendPercent(): String { + val percent = tokenQuotesShort.h24ChangePercent + return percent.format { percent() } + } +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/market/state/SwapMarketState.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/market/state/SwapMarketState.kt new file mode 100644 index 0000000000..bcd1c46b86 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/market/state/SwapMarketState.kt @@ -0,0 +1,26 @@ +package com.tangem.features.onramp.swap.availablepairs.market.state + +import androidx.compose.runtime.Immutable +import com.tangem.common.ui.markets.models.MarketsListItemUM +import com.tangem.domain.models.currency.CryptoCurrency +import kotlinx.collections.immutable.ImmutableList + +@Immutable +internal sealed class SwapMarketState { + + data class Content( + val items: ImmutableList, + val total: Int, + val loadMore: () -> Unit, + val onItemClick: (MarketsListItemUM) -> Unit, + val visibleIdsChanged: (List) -> Unit, + ) : SwapMarketState() + + data object Loading : SwapMarketState() + + data class LoadingError( + val onRetryClicked: () -> Unit, + ) : SwapMarketState() + + data object SearchNothingFound : SwapMarketState() +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AvailableSwapPairsModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AvailableSwapPairsModel.kt index d862519a45..84d7dc9a8f 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AvailableSwapPairsModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AvailableSwapPairsModel.kt @@ -18,6 +18,7 @@ import com.tangem.domain.core.utils.getOrElse import com.tangem.domain.core.utils.lceContent import com.tangem.domain.core.utils.lceError import com.tangem.domain.core.utils.lceLoading +import com.tangem.domain.markets.GetMarketsTokenListFlowUseCase import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.currency.CryptoCurrency @@ -30,10 +31,13 @@ import com.tangem.feature.swap.domain.models.domain.LeastTokenInfo import com.tangem.feature.swap.domain.models.domain.SwapPairLeast import com.tangem.features.onramp.impl.R import com.tangem.features.onramp.swap.availablepairs.AvailableSwapPairsComponent +import com.tangem.features.swap.SwapFeatureToggles import com.tangem.features.onramp.swap.availablepairs.entity.transformers.SetErrorWarningTransformer import com.tangem.features.onramp.swap.availablepairs.entity.transformers.SetLoadingTokenItemsTransformer import com.tangem.features.onramp.swap.availablepairs.entity.transformers.SetNoAvailablePairsTransformer import com.tangem.features.onramp.swap.availablepairs.entity.transformers.SetNoAvailablePairsTransformerV2 +import com.tangem.features.onramp.swap.availablepairs.market.SwapMarketsListBatchFlowManager +import com.tangem.features.onramp.swap.availablepairs.market.state.SwapMarketState import com.tangem.features.onramp.swap.entity.AccountAvailabilityUM import com.tangem.features.onramp.swap.entity.AccountCurrencyUM import com.tangem.features.onramp.tokenlist.entity.TokenListUM @@ -43,6 +47,7 @@ import com.tangem.features.onramp.tokenlist.entity.transformer.* import com.tangem.features.onramp.utils.UpdateSearchBarActiveStateTransformer import com.tangem.features.onramp.utils.UpdateSearchBarCallbacksTransformer import com.tangem.features.onramp.utils.UpdateSearchQueryTransformer +import com.tangem.utils.Provider import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.runSuspendCatching import kotlinx.coroutines.flow.* @@ -64,6 +69,8 @@ internal class AvailableSwapPairsModel @Inject constructor( private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase, private val accountsFeatureToggles: AccountsFeatureToggles, + private val getMarketsTokenListFlowUseCase: GetMarketsTokenListFlowUseCase, + private val swapFeatureToggles: SwapFeatureToggles, getWalletsUseCase: GetWalletsUseCase, ) : Model() { @@ -76,6 +83,22 @@ internal class AvailableSwapPairsModel @Inject constructor( private val accountListFlow = getAccountListUseCaseFlow() private val availablePairsByNetworkFlow = MutableStateFlow>(emptyMap()) + private val selectedAppCurrencyFlow: StateFlow = getSelectedAppCurrencyUseCase.invokeOrDefault() + .stateIn(scope = modelScope, started = SharingStarted.Eagerly, initialValue = AppCurrency.Default) + private val searchQueryStateForMarkets = MutableStateFlow("") + private val visibleMarketItemIds = MutableStateFlow>(emptyList()) + + private val searchMarketsListManager by lazy { + SwapMarketsListBatchFlowManager( + getMarketsTokenListFlowUseCase = getMarketsTokenListFlowUseCase, + batchFlowType = GetMarketsTokenListFlowUseCase.BatchFlowType.Search, + currentAppCurrency = Provider { selectedAppCurrencyFlow.value }, + currentSearchText = Provider { searchQueryStateForMarkets.value }, + modelScope = modelScope, + dispatchers = dispatchers, + ) + } + init { if (accountsFeatureToggles.isFeatureEnabled) { subscribeOnUpdateStateV2() @@ -85,6 +108,11 @@ internal class AvailableSwapPairsModel @Inject constructor( initializeSearchBarCallbacks() subscribeOnAvailablePairsUpdates() + + if (swapFeatureToggles.isMarketListFeatureEnabled) { + subscribeOnMarketsUpdates() + subscribeOnVisibleMarketItems() + } } private fun getTokenListUseCaseFlow(): SharedFlow> { @@ -421,6 +449,8 @@ internal class AvailableSwapPairsModel @Inject constructor( tokenListUMController.update(transformer = UpdateSearchQueryTransformer(newQuery)) searchManager.update(newQuery) + + searchQueryStateForMarkets.value = newQuery } } @@ -482,4 +512,59 @@ internal class AvailableSwapPairsModel @Inject constructor( network = currency.network.backendId, ) } + + @Suppress("LongMethod") + private fun subscribeOnMarketsUpdates() { + combine( + flow = searchQueryStateForMarkets + .onEach { searchQuery -> + if (searchQuery.isNotEmpty()) { + searchMarketsListManager.reload(searchQuery) + } + }, + flow2 = searchMarketsListManager.uiItems, + flow3 = searchMarketsListManager.isInInitialLoadingErrorState, + flow4 = searchMarketsListManager.isSearchNotFoundState, + flow5 = searchMarketsListManager.totalCount, + ) { searchQuery, uiItems, isError, isSearchNotFound, total -> + when { + searchQuery.isEmpty() -> { + visibleMarketItemIds.value = emptyList() + null + } + isError -> SwapMarketState.LoadingError( + onRetryClicked = { searchMarketsListManager.reload(searchQuery) }, + ) + isSearchNotFound -> SwapMarketState.SearchNothingFound + uiItems.isEmpty() -> SwapMarketState.Loading + else -> SwapMarketState.Content( + items = uiItems, + loadMore = { searchMarketsListManager.loadMore() }, + onItemClick = {}, + visibleIdsChanged = { visibleMarketItemIds.value = it }, + total = total ?: uiItems.size, + ) + } + } + .distinctUntilChanged() + .onEach { marketsState -> + tokenListUMController.update { it.copy(marketsState = marketsState) } + } + .flowOn(dispatchers.main) + .launchIn(modelScope) + } + + private fun subscribeOnVisibleMarketItems() { + modelScope.launch { + visibleMarketItemIds.mapNotNull { rawIds -> + if (rawIds.isNotEmpty()) { + searchMarketsListManager.getBatchKeysByItemIds(rawIds) + } else { + null + } + }.distinctUntilChanged().collectLatest { visibleBatchKeys -> + searchMarketsListManager.loadCharts(visibleBatchKeys) + } + } + } } \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/ui/SwapMarketsListItems.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/ui/SwapMarketsListItems.kt new file mode 100644 index 0000000000..b8e098fa31 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/ui/SwapMarketsListItems.kt @@ -0,0 +1,113 @@ +package com.tangem.features.onramp.swap.availablepairs.ui + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +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 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.R +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.features.onramp.swap.availablepairs.market.state.SwapMarketState + +private const val LOADING_PLACEHOLDERS_COUNT = 20 + +internal fun LazyListScope.swapMarketsListItems(state: SwapMarketState) { + item(key = "markets_title") { + val totalCount = (state as? SwapMarketState.Content)?.total + Text( + 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) + .padding(top = TangemTheme.dimens.spacing24, bottom = TangemTheme.dimens.spacing12), + ) + } + + when (state) { + is SwapMarketState.Loading -> { + items(count = LOADING_PLACEHOLDERS_COUNT, 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, + ) + } +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/ui/SwapSelectTokens.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/ui/SwapSelectTokens.kt index 346d2b498f..5d3e457cc8 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/ui/SwapSelectTokens.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/ui/SwapSelectTokens.kt @@ -8,24 +8,33 @@ import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState 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.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.unit.dp import com.tangem.common.ui.notifications.NotificationUM import com.tangem.core.ui.components.appbar.AppBarWithBackButton +import com.tangem.core.ui.components.list.InfiniteListHandler import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.utils.rememberHideKeyboardNestedScrollConnection import com.tangem.features.onramp.impl.R import com.tangem.features.onramp.swap.availablepairs.AvailableSwapPairsComponent +import com.tangem.features.onramp.swap.availablepairs.market.state.SwapMarketState import com.tangem.features.onramp.swap.entity.ExchangeCardUM import com.tangem.features.onramp.swap.entity.SwapSelectTokensUM import com.tangem.features.onramp.tokenlist.OnrampTokenListComponent import com.tangem.features.onramp.tokenlist.entity.TokenListUM +private const val LOAD_MORE_BUFFER = 25 + /** * Swap select tokens * @@ -49,8 +58,8 @@ internal fun SwapSelectTokens( BackHandler(onBack = state.onBackClick) val nestedScrollConnection = rememberHideKeyboardNestedScrollConnection() - val lazyListState = rememberLazyListState() + LazyColumn( modifier = modifier .nestedScroll(nestedScrollConnection) @@ -60,62 +69,146 @@ internal fun SwapSelectTokens( state = lazyListState, contentPadding = PaddingValues(bottom = 8.dp), ) { - stickyHeader(key = "header") { - AppBarWithBackButton( - onBackClick = state.onBackClick, - text = stringResourceSafe(id = R.string.common_swap), - iconRes = R.drawable.ic_close_24, - containerColor = TangemTheme.colors.background.secondary, - ) - } + swapSelectTokensContent( + state = state, + selectFromTokenListComponent = selectFromTokenListComponent, + selectFromTokenListState = selectFromTokenListState, + selectToTokenListComponent = selectToTokenListComponent, + selectToTokenListState = selectToTokenListState, + ) + } - item(key = "exchange_from", contentType = "exchange_from") { + ScrollToTopEffect(state = state, lazyListState = lazyListState) + + MarketsHandlers( + state = state, + selectToTokenListState = selectToTokenListState, + lazyListState = lazyListState, + ) +} + +@OptIn(ExperimentalFoundationApi::class) +private fun LazyListScope.swapSelectTokensContent( + state: SwapSelectTokensUM, + selectFromTokenListComponent: OnrampTokenListComponent, + selectFromTokenListState: TokenListUM, + selectToTokenListComponent: AvailableSwapPairsComponent, + selectToTokenListState: TokenListUM, +) { + stickyHeader(key = "header") { + AppBarWithBackButton( + onBackClick = state.onBackClick, + text = stringResourceSafe(id = R.string.common_swap), + iconRes = R.drawable.ic_close_24, + containerColor = TangemTheme.colors.background.secondary, + ) + } + + item(key = "exchange_from", contentType = "exchange_from") { + ExchangeCard( + state = state.exchangeFrom, + isBalanceHidden = state.isBalanceHidden, + modifier = Modifier + .padding(horizontal = 16.dp) + .padding(top = 8.dp, bottom = 12.dp) + .animateItem(), + ) + } + + if (state.exchangeFrom is ExchangeCardUM.Empty) { + with(selectFromTokenListComponent) { + content(uiState = selectFromTokenListState, modifier = Modifier) + } + } + + if (state.exchangeFrom is ExchangeCardUM.Filled) { + exchangeToSection( + state = state, + selectToTokenListComponent = selectToTokenListComponent, + selectToTokenListState = selectToTokenListState, + ) + } +} + +@OptIn(ExperimentalFoundationApi::class) +private fun LazyListScope.exchangeToSection( + state: SwapSelectTokensUM, + selectToTokenListComponent: AvailableSwapPairsComponent, + selectToTokenListState: TokenListUM, +) { + item(key = "exchange_to", contentType = "exchange_to") { + if (selectToTokenListState.warning != NotificationUM.Warning.SwapNoAvailablePair) { ExchangeCard( - state = state.exchangeFrom, + state = state.exchangeTo, isBalanceHidden = state.isBalanceHidden, modifier = Modifier .padding(horizontal = 16.dp) - .padding(top = 8.dp, bottom = 12.dp) + .padding(bottom = 12.dp) .animateItem(), ) } - - if (state.exchangeFrom is ExchangeCardUM.Empty) { - with(selectFromTokenListComponent) { - content( - uiState = selectFromTokenListState, - modifier = Modifier, - ) - } - } - - if (state.exchangeFrom is ExchangeCardUM.Filled) { - item(key = "exchange_to", contentType = "exchange_to") { - if (selectToTokenListState.warning != NotificationUM.Warning.SwapNoAvailablePair) { - ExchangeCard( - state = state.exchangeTo, - isBalanceHidden = state.isBalanceHidden, - modifier = Modifier - .padding(horizontal = 16.dp) - .padding(bottom = 12.dp) - .animateItem(), - ) - } - } - - if (state.exchangeTo is ExchangeCardUM.Empty) { - with(selectToTokenListComponent) { - content( - uiState = selectToTokenListState, - modifier = Modifier.padding(horizontal = 16.dp), - ) - } - } - } } - // scroll to top after "from" token selection + if (state.exchangeTo is ExchangeCardUM.Empty) { + with(selectToTokenListComponent) { + content(uiState = selectToTokenListState, modifier = Modifier.padding(horizontal = 16.dp)) + } + } +} + +@Composable +private fun ScrollToTopEffect(state: SwapSelectTokensUM, lazyListState: LazyListState) { LaunchedEffect(state.exchangeFrom !is ExchangeCardUM.Empty) { lazyListState.scrollToItem(index = 0) } +} + +@Composable +private fun MarketsHandlers( + state: SwapSelectTokensUM, + selectToTokenListState: TokenListUM, + lazyListState: LazyListState, +) { + // Markets for "to" token list + if (state.exchangeFrom is ExchangeCardUM.Filled && state.exchangeTo is ExchangeCardUM.Empty) { + MarketsPaginationHandler( + marketsState = selectToTokenListState.marketsState, + lazyListState = lazyListState, + ) + } +} + +@Composable +private fun MarketsPaginationHandler(marketsState: SwapMarketState?, lazyListState: LazyListState) { + (marketsState as? SwapMarketState.Content)?.let { content -> + VisibleItemsTracker(lazyListState = lazyListState, marketState = content) + + InfiniteListHandler( + listState = lazyListState, + buffer = LOAD_MORE_BUFFER, + triggerLoadMoreCheckOnItemsCountChange = true, + onLoadMore = remember(content) { + { + content.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) + } } \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/TokenListUM.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/TokenListUM.kt index 3f88743a05..04f7008ff8 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/TokenListUM.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/TokenListUM.kt @@ -3,6 +3,7 @@ package com.tangem.features.onramp.tokenlist.entity import com.tangem.common.ui.notifications.NotificationUM import com.tangem.core.ui.components.fields.entity.SearchBarUM import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM +import com.tangem.features.onramp.swap.availablepairs.market.state.SwapMarketState import kotlinx.collections.immutable.ImmutableList /** @@ -12,6 +13,7 @@ import kotlinx.collections.immutable.ImmutableList * @property availableItems available items (search bar, header, tokens) * @property unavailableItems unavailable items (header, tokens) * @property isBalanceHidden flag that indicates if balance should be hidden + * @property marketsState markets list state (null when markets should not be shown) * [REDACTED_AUTHOR] */ @@ -22,16 +24,24 @@ internal data class TokenListUM( val tokensListData: TokenListUMData, val isBalanceHidden: Boolean, val warning: NotificationUM? = null, + val marketsState: SwapMarketState? = null, ) internal sealed interface TokenListUMData { + + val totalTokensCount: Int + data class AccountList( val tokensList: ImmutableList, + override val totalTokensCount: Int, ) : TokenListUMData data class TokenList( val tokensList: ImmutableList, + override val totalTokensCount: Int, ) : TokenListUMData - data object EmptyList : TokenListUMData + data object EmptyList : TokenListUMData { + override val totalTokensCount: Int = 0 + } } \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/SetLoadingAccountTokenListTransformer.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/SetLoadingAccountTokenListTransformer.kt index 48d7b7331d..b3a75c9a89 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/SetLoadingAccountTokenListTransformer.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/SetLoadingAccountTokenListTransformer.kt @@ -19,6 +19,12 @@ internal class SetLoadingAccountTokenListTransformer( private val accountListItemConverter = LoadingAccountTokenItemConverter(appCurrency) override fun transform(prevState: TokenListUM): TokenListUM { + val totalTokensCount = accountList.sumOf { account -> + when (account) { + is AccountStatus.CryptoPortfolio -> account.tokenList.flattenCurrencies().size + } + } + return prevState.copy( availableItems = persistentListOf(), unavailableItems = persistentListOf(), @@ -27,6 +33,7 @@ internal class SetLoadingAccountTokenListTransformer( tokensList = accountListItemConverter.convertList( accountList.filterIsInstance(), ).toPersistentList(), + totalTokensCount = totalTokensCount, ) } else { TokenListUMData.TokenList( @@ -37,6 +44,7 @@ internal class SetLoadingAccountTokenListTransformer( ) } }.toPersistentList(), + totalTokensCount = totalTokensCount, ) }, warning = null, diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/SetNothingToFoundStateTransformerV2.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/SetNothingToFoundStateTransformerV2.kt index ddc0a2ac1f..422e445dff 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/SetNothingToFoundStateTransformerV2.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/SetNothingToFoundStateTransformerV2.kt @@ -23,6 +23,7 @@ internal class SetNothingToFoundStateTransformerV2( text = emptySearchMessageReference, ), ), + totalTokensCount = 0, ), isBalanceHidden = isBalanceHidden, ) diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/UpdateAccountTokenListTransformer.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/UpdateAccountTokenListTransformer.kt index 51fe76f5af..8ccc9dabe0 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/UpdateAccountTokenListTransformer.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/UpdateAccountTokenListTransformer.kt @@ -40,12 +40,15 @@ internal class UpdateAccountTokenListTransformer( .createUnavailableItemConverterV2(appCurrency = appCurrency, unavailableErrorText = unavailableErrorText) override fun transform(prevState: TokenListUM): TokenListUM { + val totalTokensCount = accountList.sumOf { it.currencyList.size } + return prevState.copy( availableItems = persistentListOf(), unavailableItems = persistentListOf(), tokensListData = if (isAccountsMode) { TokenListUMData.AccountList( tokensList = accountListItemConverter.convertList(accountList).toPersistentList(), + totalTokensCount = totalTokensCount, ) } else { val tokensList = accountList.flatMap { (_, currencyList) -> @@ -66,6 +69,7 @@ internal class UpdateAccountTokenListTransformer( text = resourceReference(R.string.exchange_tokens_available_tokens_header), ), ) + tokensList, + totalTokensCount = totalTokensCount, ) } else { TokenListUMData.EmptyList diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/ui/OnrampTokenList.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/ui/OnrampTokenList.kt index aefd164597..6cecf0ac0e 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/ui/OnrampTokenList.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/ui/OnrampTokenList.kt @@ -3,18 +3,25 @@ package com.tangem.features.onramp.tokenlist.ui import android.content.res.Configuration import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.background +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn 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.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import com.tangem.common.ui.notifications.NotificationUM +import com.tangem.core.ui.R +import com.tangem.core.ui.components.SpacerH32 import com.tangem.core.ui.components.fields.SearchBar import com.tangem.core.ui.components.fields.TangemSearchBarDefaults import com.tangem.core.ui.components.fields.entity.SearchBarUM @@ -25,25 +32,80 @@ 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.conditional +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.utils.lazyListItemPosition +import com.tangem.features.onramp.swap.availablepairs.ui.swapMarketsListItems import com.tangem.features.onramp.tokenlist.entity.TokenListUM import com.tangem.features.onramp.tokenlist.entity.TokenListUMData import com.tangem.features.onramp.tokenlist.ui.preview.PreviewTokenListUMProvider import kotlinx.collections.immutable.ImmutableList /** - * Token list + * Token list for swap - automatically switches between normal and search mode with markets * - * @param state state + * @param state state * -[REDACTED_AUTHOR] + */ +internal fun LazyListScope.onrampSwapTokenList(state: TokenListUM) { + val isSearchMode = state.searchBarUM.query.isNotEmpty() && state.marketsState != null + if (isSearchMode) { + onrampTokenListWithMarkets(state = state) + } else { + onrampTokenList(state = state) + } +} + +/** + * Token list - normal mode (without markets) + * + * @param state state */ internal fun LazyListScope.onrampTokenList(state: TokenListUM) { val itemModifier = Modifier.padding(horizontal = 16.dp) + warningOrSearchBar(state = state, itemModifier = itemModifier) + + tokensList(items = state.availableItems, isBalanceHidden = state.isBalanceHidden) + + tokensList(items = state.unavailableItems, isBalanceHidden = state.isBalanceHidden) + + tokensListData(state = state) +} + +/** + * Token list with markets - search mode + * + * @param state state + */ +private fun LazyListScope.onrampTokenListWithMarkets(state: TokenListUM) { + val itemModifier = Modifier.padding(horizontal = 16.dp) + + warningOrSearchBar(state = state, itemModifier = itemModifier) + + // Check if user has any assets to show + val hasAssets = state.availableItems.isNotEmpty() || + state.unavailableItems.isNotEmpty() || + state.tokensListData.totalTokensCount != 0 + + if (hasAssets) { + assetsTitle(count = state.tokensListData.totalTokensCount) + + tokensList(items = state.availableItems, isBalanceHidden = state.isBalanceHidden) + + tokensList(items = state.unavailableItems, isBalanceHidden = state.isBalanceHidden) + + tokensListData(state = state) + + item { SpacerH32() } + } + + state.marketsState?.let(::swapMarketsListItems) +} + +private fun LazyListScope.warningOrSearchBar(state: TokenListUM, itemModifier: Modifier) { if (state.warning == null) { searchBarItem(searchBarUM = state.searchBarUM, modifier = itemModifier) } else { @@ -67,11 +129,9 @@ internal fun LazyListScope.onrampTokenList(state: TokenListUM) { } } } +} - tokensList(items = state.availableItems, isBalanceHidden = state.isBalanceHidden) - - tokensList(items = state.unavailableItems, isBalanceHidden = state.isBalanceHidden) - +private fun LazyListScope.tokensListData(state: TokenListUM) { when (val list = state.tokensListData) { is TokenListUMData.AccountList -> list.tokensList.forEach { item -> portfolioTokensList( @@ -99,6 +159,28 @@ private fun LazyListScope.searchBarItem(searchBarUM: SearchBarUM, modifier: Modi } } +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.tokensList(items: ImmutableList, isBalanceHidden: Boolean) { itemsIndexed( items = items,