Updated on 2026-08-14

This commit is contained in:
Tangem 2024-08-05 15:22:04 +03:00
parent af657b67fc
commit 9d5a56d97a
12 changed files with 201 additions and 101 deletions

View file

@ -16,8 +16,8 @@ interface TangemTechMarketsApi {
@Query("offset") offset: Int,
@Query("limit") limit: Int,
@Query("order") order: String,
@Query("general_coins") generalCoins: Boolean,
@Query("search") search: String?,
@Query("timestamp") timestamp: Long?,
): ApiResponse<TokenMarketListResponse>
@GET("coins/{coin_id}")

View file

@ -14,6 +14,8 @@ data class TokenMarketListResponse(
val limit: Int,
@Json(name = "offset")
val offset: Int,
@Json(name = "timestamp")
val timestamp: Long? = null,
) {
data class Token(
@Json(name = "id")

View file

@ -114,6 +114,12 @@ private class DefaultBatchListSource<TKey, TData, TRequestParams : Any, TUpdate>
loadMoreActionJob?.cancel()
reloadActionJob?.cancel()
stopAllUpdates()
state.value = BatchListState(
data = emptyList(),
status = PaginationStatus.InitialLoading,
)
reloadActionJob = scope.launchFetch {
reloadTask(action)
}
@ -221,11 +227,6 @@ private class DefaultBatchListSource<TKey, TData, TRequestParams : Any, TUpdate>
}
private suspend fun reloadTask(action: BatchAction.Reload<TRequestParams>) {
state.value = BatchListState(
data = emptyList(),
status = PaginationStatus.InitialLoading,
)
val res = runCatching {
batchFetcher.fetchFirst(action.requestParams)
}.getOrElse {

View file

@ -10,6 +10,7 @@ import com.tangem.domain.markets.repositories.MarketsTokenRepository
import com.tangem.pagination.*
import com.tangem.pagination.fetcher.LimitOffsetBatchFetcher
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicLong
internal class DefaultMarketsTokenRepository(
@ -42,9 +43,9 @@ internal class DefaultMarketsTokenRepository(
interval = request.params.priceChangeInterval.toRequestParam(),
order = request.params.order.toRequestParam(),
search = searchText,
generalCoins = request.params.showUnder100kMarketCapTokens.not(),
offset = request.offset,
limit = request.limit,
timestamp = if (isFirstBatchFetching) null else requestTimeStamp.get(),
).getOrThrow()
}
@ -58,7 +59,7 @@ internal class DefaultMarketsTokenRepository(
}
if (isFirstBatchFetching) {
requestTimeStamp.set(0) // TODO when backend is ready
requestTimeStamp.set(res.timestamp ?: 0)
}
val last = res.tokens.size < request.limit
@ -82,10 +83,12 @@ internal class DefaultMarketsTokenRepository(
marketsApi = marketsApi,
)
val atomicInteger = AtomicInteger(0)
return BatchListSource(
fetchDispatcher = dispatcherProvider.io,
context = batchingContext,
generateNewKey = { it.size },
generateNewKey = { atomicInteger.getAndIncrement() },
batchFetcher = createTokenMarketsFetcher(firstBatchSize = firstBatchSize, nextBatchSize = nextBatchSize),
updateFetcher = tokenMarketsUpdateFetcher,
).toBatchFlow()

View file

@ -3,7 +3,6 @@ package com.tangem.domain.markets
data class TokenMarketListConfig(
val fiatPriceCurrency: String,
val searchText: String?,
val showUnder100kMarketCapTokens: Boolean,
val priceChangeInterval: Interval,
val order: Order,
) {

View file

@ -1,11 +1,9 @@
package com.tangem.features.markets.tokenlist.impl
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.lifecycle.compose.LifecycleStartEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
@ -40,6 +38,13 @@ class DefaultMarketsTokenListComponent @AssistedInject constructor(
onHeaderSizeChange: (Dp) -> Unit,
modifier: Modifier,
) {
LifecycleStartEffect(Unit) {
model.isVisibleOnScreen.value = true
onStopOrDispose {
model.isVisibleOnScreen.value = false
}
}
val state by model.state.collectAsStateWithLifecycle()
val bsState by bottomSheetState

View file

@ -82,6 +82,7 @@ internal class MarketsListModel @Inject constructor(
val tokenSelected = _tokenSelected.asSharedFlow()
val containerBottomSheetState = MutableStateFlow(BottomSheetState.COLLAPSED)
val isVisibleOnScreen = MutableStateFlow(false)
val state = marketsListUMStateManager.state.asStateFlow()
@ -201,6 +202,7 @@ internal class MarketsListModel @Inject constructor(
marketsListUMStateManager.searchQueryFlow
.filter { it.isNotEmpty() }
.debounce(timeoutMillis = SEARCH_QUERY_DEBOUNCE_MILLIS)
.distinctUntilChanged()
.filter { activeListManager == searchMarketsListManager }
.collectLatest {
searchMarketsListManager.reload(searchText = it)
@ -233,7 +235,10 @@ internal class MarketsListModel @Inject constructor(
delay(timeMillis)
// Update quotes only when the container bottom sheet is in the expanded state
containerBottomSheetState.first { it == BottomSheetState.EXPANDED }
activeListManager.updateQuotes() // TODO update a batch that is currently on screen
// and is visible on the screen
isVisibleOnScreen.first { it }
activeListManager.updateQuotes()
}
}.saveIn(updateQuotesJob)
}

View file

@ -33,7 +33,7 @@ internal class MarketsTokenItemConverter(
trendPercentText = value.getTrendPercent(),
trendType = value.getTrendType(),
chardData = value.getChartData(),
showUnder100kMarketCap = value.isUnder100kMarketCap(),
isUnder100kMarketCap = value.isUnder100kMarketCap(),
)
}
@ -147,7 +147,7 @@ internal class MarketsTokenItemConverter(
}
private fun TokenMarket.isUnder100kMarketCap(): Boolean {
return tokenQuotes.currentPrice.compareTo(decimal100k) == -1
return marketCap?.let { it < decimal100k } ?: true
}
private companion object {

View file

@ -9,15 +9,19 @@ import com.tangem.features.markets.tokenlist.impl.model.utils.logUpdateResults
import com.tangem.features.markets.tokenlist.impl.ui.entity.MarketsListItemUM
import com.tangem.features.markets.tokenlist.impl.ui.entity.MarketsListUM.TrendInterval
import com.tangem.features.markets.tokenlist.impl.ui.entity.SortByTypeUM
import com.tangem.pagination.*
import com.tangem.pagination.Batch
import com.tangem.pagination.BatchAction
import com.tangem.pagination.BatchFetchResult
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.CoroutineScope
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
private const val LOG_EVENTS = true
@ -33,6 +37,7 @@ internal class MarketsListBatchFlowManager(
private val dispatchers: CoroutineDispatcherProvider,
) {
private val actionsFlow = MutableSharedFlow<BatchAction<Int, TokenMarketListConfig, TokenMarketUpdateRequest>>()
private val updateStateJob = JobHolder()
private val batchFlow = getMarketsTokenListFlowUseCase(
batchingContext = TokenListBatchingContext(
@ -42,6 +47,9 @@ internal class MarketsListBatchFlowManager(
batchFlowType = batchFlowType,
)
private val resultBatches = MutableStateFlow(ResultBatches())
private val uiBatches = resultBatches.map { it.uiBatches }
val uiItems: StateFlow<ImmutableList<MarketsListItemUM>>
get() = uiBatches
.map { batches ->
@ -97,16 +105,20 @@ internal class MarketsListBatchFlowManager(
initialValue = false,
)
private val uiBatches = MutableStateFlow<List<Batch<Int, List<MarketsListItemUM>>>>(emptyList())
init {
batchFlow.state
.map { it.data }
.distinctUntilChanged { a, b ->
a.size == b.size && a.map { it.data }.flatten() == b.map { it.data }.flatten()
a.size == b.size &&
a.map { it.key } == b.map { it.key } &&
a.map { it.data }.flatten() == b.map { it.data }.flatten()
}
.onEachWithPrevious { prev, list ->
updateState(prev, list)
.onEach {
coroutineScope {
launch {
updateState(it)
}.saveIn(updateStateJob)
}
}
.flowOn(dispatchers.default)
.launchIn(modelScope)
@ -127,58 +139,75 @@ internal class MarketsListBatchFlowManager(
}
}
private fun updateState(
previousList: List<Batch<Int, List<TokenMarket>>>?,
list: List<Batch<Int, List<TokenMarket>>>,
forceUpdate: Boolean = false,
) = uiBatches.update { items ->
val converter = MarketsTokenItemConverter(currentTrendInterval(), appCurrency = currentAppCurrency())
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
if (previousList == null || list.size < previousList.size || forceUpdate) {
list.map {
Batch(
key = it.key,
data = converter.convertList(it.data),
val converter = MarketsTokenItemConverter(currentTrendInterval(), 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(
key = it.key,
data = converter.convertList(it.data),
)
}
} else {
previousList!!
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(
key = it.key,
data = converter.convertList(it.data),
)
}
} else {
items.mapIndexed { batchIndex, batch ->
val prevBatch = previousList[batchIndex]
val newBatch = newList[batchIndex]
if (previousList == newBatch) return@mapIndexed batch
Batch(
key = batch.key,
data = batch.data.mapIndexed { index, marketsListItemUM ->
val prevItem = prevBatch.data[index]
val newItem = newBatch.data[index]
converter.update(
prevItem,
marketsListItemUM,
newItem,
)
},
)
}
}
}
currentCoroutineContext().ensureActive()
ResultBatches(
uiBatches = outItems,
processedItems = newList,
)
}
} else {
if (previousList.size != list.size) {
val keysToAdd = list.map { it.key }.subtract(previousList.map { it.key }.toSet())
val newBatches = list.filter { keysToAdd.contains(it.key) }
items + newBatches.map {
Batch(
key = it.key,
data = converter.convertList(it.data),
)
}
} else {
items.mapIndexed { batchIndex, batch ->
val prevBatch = previousList[batchIndex]
val newBatch = list[batchIndex]
if (previousList == newBatch) return@mapIndexed batch
Batch(
key = batch.key,
data = batch.data.mapIndexed { index, marketsListItemUM ->
val prevItem = prevBatch.data[index]
val newItem = newBatch.data[index]
converter.update(
prevItem,
marketsListItemUM,
newItem,
)
},
)
}
}
}
}
fun reload(searchText: String? = null) {
modelScope.launch {
uiBatches.value = emptyList()
resultBatches.value = ResultBatches()
actionsFlow.emit(
BatchAction.Reload(
requestParams = TokenMarketListConfig(
@ -188,7 +217,6 @@ internal class MarketsListBatchFlowManager(
} else {
searchText ?: currentSearchText()
},
showUnder100kMarketCapTokens = false, // TODO
priceChangeInterval = currentTrendInterval().toBatchRequestInterval(),
order = currentSortByType().toRequestOrder(),
),
@ -206,12 +234,14 @@ internal class MarketsListBatchFlowManager(
fun updateUIWithSameState() {
modelScope.launch(dispatchers.default) {
val current = batchFlow.state.value.data
updateState(current, current, forceUpdate = true)
}
updateState(current, forceUpdate = true)
}.saveIn(updateStateJob)
}
fun loadCharts(batchKeys: Set<Int>, interval: TrendInterval) {
modelScope.launch(dispatchers.default) {
if (batchKeys.isEmpty()) return
modelScope.launch {
val currentData = batchFlow.state.value.data
val alreadyLoadedChartsBatchKeys = currentData
.filter {
@ -266,7 +296,7 @@ internal class MarketsListBatchFlowManager(
}
fun clearStateAndStopAllActions() {
uiBatches.value = emptyList()
resultBatches.value = ResultBatches()
modelScope.launch {
actionsFlow.emit(BatchAction.Reset)
}
@ -303,12 +333,8 @@ internal class MarketsListBatchFlowManager(
}
}
private fun <T> Flow<T>.onEachWithPrevious(operation: suspend (prev: T?, value: T) -> Unit): Flow<T> = flow {
var prev: T? = null
collect { value ->
operation(prev, value)
prev = value
emit(value)
}
}
private data class ResultBatches(
val uiBatches: List<Batch<Int, List<MarketsListItemUM>>> = emptyList(),
val processedItems: List<Batch<Int, List<TokenMarket>>>? = null,
)
}

View file

@ -13,6 +13,8 @@ import com.tangem.features.markets.tokenlist.impl.ui.entity.MarketsListItemUM
import com.tangem.features.markets.tokenlist.impl.ui.entity.MarketsListUM
import com.tangem.features.markets.tokenlist.impl.ui.entity.SortByTypeUM
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.*
@Stable
@ -99,22 +101,74 @@ internal class MarketsListUMStateManager(
it.copy(list = ListUM.Loading)
}
else -> {
it.copy(
list = ListUM.Content(
items = uiItems,
loadMore = onLoadMoreUiItems,
visibleIdsChanged = visibleItemsChanged,
showUnder100kTokens = true,
onShowTokensUnder100kClicked = { },
triggerScrollReset = consumedEvent(),
onItemClick = onTokenClick,
),
)
it.updateItems(newItems = uiItems)
}
}
}
}
private fun MarketsListUM.updateItems(newItems: ImmutableList<MarketsListItemUM>): MarketsListUM {
val currentState = this
val isNextPageInSearch = isInSearchState && (this.list as? ListUM.Content)?.showUnder100kTokens == true
var searchUiItemsCached: ImmutableList<MarketsListItemUM> = persistentListOf()
val items = when {
isInSearchState && isNextPageInSearch.not() -> {
searchUiItemsCached = newItems
val filtered = newItems.filter { item -> item.isUnder100kMarketCap.not() }.toImmutableList()
if (filtered.size == newItems.size) {
return currentState.copy(list = generalContentState(newItems))
} else {
filtered
}
}
else -> {
searchUiItemsCached = persistentListOf()
newItems
}
}
return currentState.copy(
list = ListUM.Content(
items = items,
loadMore = onLoadMoreUiItems,
visibleIdsChanged = visibleItemsChanged,
showUnder100kTokens = isInSearchState.not() || isNextPageInSearch,
onShowTokensUnder100kClicked = {
if (searchUiItemsCached.isNotEmpty()) {
state.update { s ->
if (s.list is ListUM.Content) {
s.copy(
list = s.list.copy(
items = searchUiItemsCached,
showUnder100kTokens = true,
),
)
} else {
s
}
}
}
},
triggerScrollReset = consumedEvent(),
onItemClick = onTokenClick,
),
)
}
private fun generalContentState(newItems: ImmutableList<MarketsListItemUM>): ListUM.Content {
return ListUM.Content(
items = newItems,
loadMore = onLoadMoreUiItems,
visibleIdsChanged = visibleItemsChanged,
showUnder100kTokens = true,
onShowTokensUnder100kClicked = {},
triggerScrollReset = consumedEvent(),
onItemClick = onTokenClick,
)
}
private fun state(): MarketsListUM = MarketsListUM(
list = ListUM.Loading,
searchBar = SearchBarUM(

View file

@ -25,6 +25,7 @@ import com.tangem.features.markets.tokenlist.impl.ui.entity.ListUM
import kotlinx.coroutines.launch
private const val LOAD_NEXT_PAGE_ON_END_INDEX = 50
private const val TOKEN_LAZY_LIST_ID_SEPARATOR = "***"
@Composable
@Suppress("LongMethod")
@ -89,7 +90,7 @@ internal fun MarketsListLazyColumn(
is ListUM.Content -> {
items(
items = state.items,
key = { it.id + it.marketCap.toString() },
key = { it.id + TOKEN_LAZY_LIST_ID_SEPARATOR + it.marketCap.toString() },
) { item ->
MarketsListItem(
model = item,
@ -117,8 +118,11 @@ internal fun MarketsListLazyColumn(
buffer = LOAD_NEXT_PAGE_ON_END_INDEX,
onLoadMore = remember(state) {
{
if (state is ListUM.Content) {
if (state is ListUM.Content && state.showUnder100kTokens) {
state.loadMore()
true
} else {
false
}
}
},
@ -184,7 +188,9 @@ private fun SearchNothingFoundText(modifier: Modifier = Modifier) {
private fun VisibleItemsTracker(listState: LazyListState, state: ListUM) {
val visibleItems by remember {
derivedStateOf {
listState.layoutInfo.visibleItemsInfo.mapNotNull { it.key as? String }
listState.layoutInfo.visibleItemsInfo.mapNotNull {
(it.key as? String)?.split(TOKEN_LAZY_LIST_ID_SEPARATOR)?.first()
}
}
}
@ -196,7 +202,7 @@ private fun VisibleItemsTracker(listState: LazyListState, state: ListUM) {
}
@Composable
fun InfiniteListHandler(listState: LazyListState, onLoadMore: () -> Unit, buffer: Int = 2) {
fun InfiniteListHandler(listState: LazyListState, onLoadMore: () -> Boolean, buffer: Int = 2) {
val loadMore by remember {
derivedStateOf {
val layoutInfo = listState.layoutInfo
@ -212,8 +218,7 @@ fun InfiniteListHandler(listState: LazyListState, onLoadMore: () -> Unit, buffer
LaunchedEffect(loadMore) {
if (loadMore && !emitted) {
emitted = true
onLoadMore()
emitted = onLoadMore()
}
}
}

View file

@ -17,7 +17,7 @@ data class MarketsListItemUM(
val trendPercentText: String,
val trendType: PriceChangeType,
val chardData: MarketChartRawData?,
val showUnder100kMarketCap: Boolean = false,
val isUnder100kMarketCap: Boolean = false,
) {
val chartType: MarketChartLook.Type = when (trendType) {
PriceChangeType.UP,