Updated on 2026-08-14

This commit is contained in:
Tangem 2026-02-09 17:12:27 +03:00
parent e17029ce9b
commit 06d1f35002
14 changed files with 877 additions and 56 deletions

View file

@ -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)

View file

@ -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

View file

@ -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,

View file

@ -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<AppCurrency>,
private val currentSearchText: Provider<String?>,
private val modelScope: CoroutineScope,
private val dispatchers: CoroutineDispatcherProvider,
) {
private val actionsFlow = MutableSharedFlow<BatchAction<Int, TokenMarketListConfig, TokenMarketUpdateRequest>>()
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<ImmutableList<MarketsListItemUM>>
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<Int?> = 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<Batch<Int, List<TokenMarket>>>, 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<Int>) {
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<CryptoCurrency.RawID>): Set<Int> {
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<Batch<Int, List<MarketsListItemUM>>> = emptyList(),
val processedItems: List<Batch<Int, List<TokenMarket>>>? = null,
)
}

View file

@ -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<TokenMarket, MarketsListItemUM> {
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<TokenMarket>): List<MarketsListItemUM> = 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 <T, R> 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() }
}
}

View file

@ -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<MarketsListItemUM>,
val total: Int,
val loadMore: () -> Unit,
val onItemClick: (MarketsListItemUM) -> Unit,
val visibleIdsChanged: (List<CryptoCurrency.RawID>) -> Unit,
) : SwapMarketState()
data object Loading : SwapMarketState()
data class LoadingError(
val onRetryClicked: () -> Unit,
) : SwapMarketState()
data object SearchNothingFound : SwapMarketState()
}

View file

@ -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<Map<LeastTokenInfo, AvailablePairsState>>(emptyMap())
private val selectedAppCurrencyFlow: StateFlow<AppCurrency> = getSelectedAppCurrencyUseCase.invokeOrDefault()
.stateIn(scope = modelScope, started = SharingStarted.Eagerly, initialValue = AppCurrency.Default)
private val searchQueryStateForMarkets = MutableStateFlow("")
private val visibleMarketItemIds = MutableStateFlow<List<CryptoCurrency.RawID>>(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<List<CryptoCurrencyStatus>> {
@ -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)
}
}
}
}

View file

@ -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,
)
}
}

View file

@ -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)
}
}

View file

@ -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<TokensListItemUM.Portfolio>,
override val totalTokensCount: Int,
) : TokenListUMData
data class TokenList(
val tokensList: ImmutableList<TokensListItemUM>,
override val totalTokensCount: Int,
) : TokenListUMData
data object EmptyList : TokenListUMData
data object EmptyList : TokenListUMData {
override val totalTokensCount: Int = 0
}
}

View file

@ -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<AccountStatus.CryptoPortfolio>(),
).toPersistentList(),
totalTokensCount = totalTokensCount,
)
} else {
TokenListUMData.TokenList(
@ -37,6 +44,7 @@ internal class SetLoadingAccountTokenListTransformer(
)
}
}.toPersistentList(),
totalTokensCount = totalTokensCount,
)
},
warning = null,

View file

@ -23,6 +23,7 @@ internal class SetNothingToFoundStateTransformerV2(
text = emptySearchMessageReference,
),
),
totalTokensCount = 0,
),
isBalanceHidden = isBalanceHidden,
)

View file

@ -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

View file

@ -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<TokensListItemUM>, isBalanceHidden: Boolean) {
itemsIndexed(
items = items,