From 0ced6def88d5ca9483c3881f92c7993540e68b2a Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 13 Jan 2026 15:12:33 +0300 Subject: [PATCH] Updated on 2026-08-14 --- .../configs/feature_toggles_config.json | 4 + .../features/swap/SwapFeatureToggles.kt | 4 +- features/swap/impl/build.gradle.kts | 3 + .../feature/swap/DefaultSwapFeatureToggles.kt | 8 +- .../swap/converters/TokensDataConverterV2.kt | 2 + .../feature/swap/di/SwapFeatureModule.kt | 5 +- .../tangem/feature/swap/model/SwapModel.kt | 82 +++++- .../swap/models/SwapSelectTokenStateHolder.kt | 14 +- .../market/SwapMarketsListBatchFlowManager.kt | 234 ++++++++++++++++++ .../SwapMarketsTokenItemConverter.kt | 148 +++++++++++ .../models/market/state/SwapMarketState.kt | 25 ++ .../tangem/feature/swap/ui/StateBuilder.kt | 3 +- 12 files changed, 524 insertions(+), 8 deletions(-) create mode 100644 features/swap/impl/src/main/java/com/tangem/feature/swap/models/market/SwapMarketsListBatchFlowManager.kt create mode 100644 features/swap/impl/src/main/java/com/tangem/feature/swap/models/market/converter/SwapMarketsTokenItemConverter.kt create mode 100644 features/swap/impl/src/main/java/com/tangem/feature/swap/models/market/state/SwapMarketState.kt diff --git a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json index 6fa865b480..9531c5a206 100644 --- a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json +++ b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json @@ -66,5 +66,9 @@ { "name": "GASLESS_TRANSACTIONS_ENABLED", "version": "5.33.0" + }, + { + "name": "SWAP_MARKET_LIST_ENABLED", + "version": "undefined" } ] diff --git a/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapFeatureToggles.kt b/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapFeatureToggles.kt index e0fe076fab..d782e276ca 100644 --- a/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapFeatureToggles.kt +++ b/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapFeatureToggles.kt @@ -1,3 +1,5 @@ package com.tangem.features.swap -interface SwapFeatureToggles \ No newline at end of file +interface SwapFeatureToggles { + val isMarketListFeatureEnabled: Boolean +} \ No newline at end of file diff --git a/features/swap/impl/build.gradle.kts b/features/swap/impl/build.gradle.kts index 9be6f2453e..ebb3536b3e 100644 --- a/features/swap/impl/build.gradle.kts +++ b/features/swap/impl/build.gradle.kts @@ -23,6 +23,8 @@ dependencies { implementation(projects.common.ui) implementation(projects.core.decompose) // For Route supertype implementation(projects.core.error) + implementation(projects.common.uiMarkets) + implementation(projects.common.uiCharts) /** Domain modules **/ implementation(projects.domain.models) @@ -48,6 +50,7 @@ dependencies { implementation(projects.domain.account) implementation(projects.domain.account.status) implementation(projects.domain.visa) + implementation(projects.domain.markets) /** Feature modules */ implementation(projects.features.swap.domain) diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapFeatureToggles.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapFeatureToggles.kt index c202111fb6..60d50e49f0 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapFeatureToggles.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapFeatureToggles.kt @@ -1,5 +1,11 @@ package com.tangem.feature.swap +import com.tangem.core.configtoggle.feature.FeatureTogglesManager import com.tangem.features.swap.SwapFeatureToggles -internal class DefaultSwapFeatureToggles : SwapFeatureToggles \ No newline at end of file +internal class DefaultSwapFeatureToggles( + private val featureTogglesManager: FeatureTogglesManager, +) : SwapFeatureToggles { + override val isMarketListFeatureEnabled: Boolean + get() = featureTogglesManager.isFeatureEnabled("SWAP_MARKET_LIST_ENABLED") +} \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/TokensDataConverterV2.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/TokensDataConverterV2.kt index 90dd691aaa..e7b9c5ee8c 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/TokensDataConverterV2.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/TokensDataConverterV2.kt @@ -31,6 +31,7 @@ internal class TokensDataConverterV2( override fun transform(prevState: SwapStateHolder): SwapStateHolder { val accountList = tokensDataState.accountCurrencyList + val currentMarketsState = prevState.selectTokenState?.marketsState return prevState.copy( selectTokenState = SwapSelectTokenStateHolder( availableTokens = persistentListOf(), @@ -57,6 +58,7 @@ internal class TokensDataConverterV2( }.toPersistentList(), ) }, + marketsState = currentMarketsState, onSearchEntered = onSearchEntered, onTokenSelected = onTokenSelected, isBalanceHidden = isBalanceHidden, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/di/SwapFeatureModule.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/di/SwapFeatureModule.kt index 990f8c8ec9..5cf4ea502f 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/di/SwapFeatureModule.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/di/SwapFeatureModule.kt @@ -1,5 +1,6 @@ package com.tangem.feature.swap.di +import com.tangem.core.configtoggle.feature.FeatureTogglesManager import com.tangem.feature.swap.DefaultSwapComponent import com.tangem.feature.swap.DefaultSwapFeatureToggles import com.tangem.features.swap.SwapComponent @@ -17,8 +18,8 @@ internal object SwapFeatureModule { @Provides @Singleton - fun provideSwapFeatureToggles(): SwapFeatureToggles { - return DefaultSwapFeatureToggles() + fun provideSwapFeatureToggles(featureTogglesManager: FeatureTogglesManager): SwapFeatureToggles { + return DefaultSwapFeatureToggles(featureTogglesManager) } } diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt index 1255f83a96..c73c840784 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt @@ -33,6 +33,7 @@ import com.tangem.domain.feedback.SaveBlockchainErrorUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.BlockchainErrorInfo import com.tangem.domain.feedback.models.FeedbackEmailType +import com.tangem.domain.markets.GetMarketsTokenListFlowUseCase import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus @@ -64,6 +65,8 @@ import com.tangem.feature.swap.domain.models.domain.* import com.tangem.feature.swap.domain.models.ui.* import com.tangem.feature.swap.models.SwapStateHolder import com.tangem.feature.swap.models.UiActions +import com.tangem.feature.swap.models.market.SwapMarketsListBatchFlowManager +import com.tangem.feature.swap.models.market.state.SwapMarketState import com.tangem.feature.swap.models.states.SwapNotificationUM import com.tangem.feature.swap.presentation.R import com.tangem.feature.swap.router.SwapNavScreen @@ -71,6 +74,7 @@ import com.tangem.feature.swap.router.SwapRouter import com.tangem.feature.swap.ui.StateBuilder import com.tangem.feature.swap.utils.formatToUIRepresentation import com.tangem.features.swap.SwapComponent +import com.tangem.features.swap.SwapFeatureToggles import com.tangem.utils.Provider import com.tangem.utils.TangemBlogUrlBuilder.RESOURCE_TO_LEARN_ABOUT_APPROVING_IN_SWAP import com.tangem.utils.coroutines.* @@ -120,6 +124,8 @@ internal class SwapModel @Inject constructor( private val getTangemPayCurrencyStatusUseCase: GetTangemPayCurrencyStatusUseCase, private val tangemPayWithdrawUseCase: TangemPayWithdrawUseCase, private val iGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork, + private val getMarketsTokenListFlowUseCase: GetMarketsTokenListFlowUseCase, + private val swapFeatureToggles: SwapFeatureToggles, ) : Model() { private val params = paramsContainer.require() @@ -160,6 +166,7 @@ internal class SwapModel @Inject constructor( ?: error("NumberFormat is not DecimalFormat"), ) private val amountDebouncer = Debouncer() + private val searchDebouncer = Debouncer() private val singleTaskScheduler = SingleTaskScheduler>() private var dataState by mutableStateOf(SwapProcessDataState()) @@ -197,6 +204,19 @@ internal class SwapModel @Inject constructor( private var isAmountChangedByUser: Boolean = false private var lastPermissionNotificationTokens: Pair? = null + private val searchQueryState = MutableStateFlow("") + private val visibleMarketItemIds = MutableStateFlow>(emptyList()) + private val searchMarketsListManager by lazy { + SwapMarketsListBatchFlowManager( + getMarketsTokenListFlowUseCase = getMarketsTokenListFlowUseCase, + batchFlowType = GetMarketsTokenListFlowUseCase.BatchFlowType.Search, + currentAppCurrency = Provider { selectedAppCurrencyFlow.value }, + currentSearchText = Provider { searchQueryState.value }, + modelScope = modelScope, + dispatchers = dispatchers, + ) + } + val currentScreen: SwapNavScreen get() = swapRouter.currentScreen @@ -259,6 +279,61 @@ internal class SwapModel @Inject constructor( uiState = stateBuilder.updateBalanceHiddenState(uiState, isBalanceHidden) } .launchIn(modelScope) + + if (swapFeatureToggles.isMarketListFeatureEnabled) { + combine( + flow = searchQueryState + .onEach { searchQuery -> + searchMarketsListManager.reload(searchQuery) + }, + flow2 = searchMarketsListManager.uiItems, + flow3 = searchMarketsListManager.isInInitialLoadingErrorState, + flow4 = searchMarketsListManager.isSearchNotFoundState, + ) { searchQuery, uiItems, isError, isSearchNotFound -> + 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 = { item -> + // TODO [REDACTED_TASK_KEY] Add currency to swap form market list + }, + visibleIdsChanged = { visibleMarketItemIds.value = it }, + ) + } + } + .distinctUntilChanged() + .onEach { marketsState -> + uiState.selectTokenState?.let { currentSelectState -> + uiState = uiState.copy( + selectTokenState = currentSelectState.copy( + marketsState = marketsState, + ), + ) + } + } + .launchIn(modelScope) + } + + modelScope.launch { + visibleMarketItemIds.mapNotNull { rawIDS -> + if (rawIDS.isNotEmpty()) { + searchMarketsListManager.getBatchKeysByItemIds(rawIDS) + } else { + null + } + }.distinctUntilChanged().collectLatest { visibleBatchKeys -> + searchMarketsListManager.loadCharts(visibleBatchKeys) + } + } } fun onStart() { @@ -987,8 +1062,10 @@ internal class SwapModel @Inject constructor( } private fun onSearchEntered(searchQuery: String) { - modelScope.launch(dispatchers.io) { - val tokenDataState = dataState.tokensDataState ?: return@launch + searchDebouncer.debounce(modelScope, DEBOUNCE_SEARCH_DELAY) { + searchQueryState.value = searchQuery + + val tokenDataState = dataState.tokensDataState ?: return@debounce val group = if (isOrderReversed) { tokenDataState.fromGroup } else { @@ -1811,6 +1888,7 @@ internal class SwapModel @Inject constructor( const val INITIAL_AMOUNT = "" const val UPDATE_DELAY = 10000L const val DEBOUNCE_AMOUNT_DELAY = 1000L + const val DEBOUNCE_SEARCH_DELAY = 500L const val UPDATE_BALANCE_DELAY_MILLIS = 11000L const val CHANGELLY_PROVIDER_ID = "changelly" } diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapSelectTokenStateHolder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapSelectTokenStateHolder.kt index 8d9c1bf701..c6408b6467 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapSelectTokenStateHolder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapSelectTokenStateHolder.kt @@ -3,12 +3,14 @@ package com.tangem.feature.swap.models import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM import com.tangem.core.ui.extensions.TextReference +import com.tangem.feature.swap.models.market.state.SwapMarketState import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf internal data class SwapSelectTokenStateHolder( val availableTokens: ImmutableList, val unavailableTokens: ImmutableList, + val marketsState: SwapMarketState? = null, val tokensListData: TokenListUMData, val isBalanceHidden: Boolean, val isAfterSearch: Boolean, @@ -51,4 +53,14 @@ internal sealed interface TokenListUMData { data object EmptyList : TokenListUMData { override val tokensList: ImmutableList = persistentListOf() } -} \ No newline at end of file +} + +internal val SwapSelectTokenStateHolder.isNotFoundState: Boolean + get() = availableTokens.isEmpty() && unavailableTokens.isEmpty() && + tokensListData.tokensList.isEmpty() && isAfterSearch && + marketsState !is SwapMarketState.Content && marketsState !is SwapMarketState.Loading + +internal val SwapSelectTokenStateHolder.isEmptyState: Boolean + get() = availableTokens.isEmpty() && unavailableTokens.isEmpty() && + tokensListData.tokensList.isEmpty() && !isAfterSearch && + marketsState !is SwapMarketState.Content && marketsState !is SwapMarketState.Loading \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/market/SwapMarketsListBatchFlowManager.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/market/SwapMarketsListBatchFlowManager.kt new file mode 100644 index 0000000000..7070acfa61 --- /dev/null +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/market/SwapMarketsListBatchFlowManager.kt @@ -0,0 +1,234 @@ +package com.tangem.feature.swap.models.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.feature.swap.models.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, + ) + + 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/swap/impl/src/main/java/com/tangem/feature/swap/models/market/converter/SwapMarketsTokenItemConverter.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/market/converter/SwapMarketsTokenItemConverter.kt new file mode 100644 index 0000000000..78c52b2aab --- /dev/null +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/market/converter/SwapMarketsTokenItemConverter.kt @@ -0,0 +1,148 @@ +package com.tangem.feature.swap.models.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.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.feature.swap.presentation.R +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 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/swap/impl/src/main/java/com/tangem/feature/swap/models/market/state/SwapMarketState.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/market/state/SwapMarketState.kt new file mode 100644 index 0000000000..8a84fb4d8b --- /dev/null +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/market/state/SwapMarketState.kt @@ -0,0 +1,25 @@ +package com.tangem.feature.swap.models.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 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/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt index 860cd0017c..fcf5220ed6 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt @@ -617,13 +617,14 @@ internal class StateBuilder( fromToken: CryptoCurrency, tokensDataState: CurrenciesGroup, ): SwapStateHolder { + val currentMarketsState = uiState.selectTokenState?.marketsState return uiState.copy( selectTokenState = tokensDataConverter.convert( value = CurrenciesGroupWithFromCurrency( fromCurrency = fromToken, group = tokensDataState, ), - ), + ).copy(marketsState = currentMarketsState), ) }