diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/markets/models/response/TokenMarketListResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/markets/models/response/TokenMarketListResponse.kt index 4157bd2bd6..b1e8675206 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/markets/models/response/TokenMarketListResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/markets/models/response/TokenMarketListResponse.kt @@ -5,7 +5,7 @@ import java.math.BigDecimal data class TokenMarketListResponse( @Json(name = "imageHost") - val imageHost: String, + val imageHost: String?, @Json(name = "tokens") val tokens: List, @Json(name = "total") diff --git a/core/pagination/src/main/java/com/tangem/pagination/BatchAction.kt b/core/pagination/src/main/java/com/tangem/pagination/BatchAction.kt index 8078c699b1..fd013069eb 100644 --- a/core/pagination/src/main/java/com/tangem/pagination/BatchAction.kt +++ b/core/pagination/src/main/java/com/tangem/pagination/BatchAction.kt @@ -68,4 +68,10 @@ sealed class BatchAction { class CancelUpdates( val predicate: (UpdateBatches) -> Boolean, ) : BatchAction() + + /** + * Clears the state and stops all current batch loading and updates + * After this status becomes [PaginationStatus.None] + */ + data object Reset : BatchAction() } \ No newline at end of file diff --git a/core/pagination/src/main/java/com/tangem/pagination/BatchFetchResult.kt b/core/pagination/src/main/java/com/tangem/pagination/BatchFetchResult.kt index a358df8bf2..f7b78b226f 100644 --- a/core/pagination/src/main/java/com/tangem/pagination/BatchFetchResult.kt +++ b/core/pagination/src/main/java/com/tangem/pagination/BatchFetchResult.kt @@ -12,10 +12,12 @@ sealed class BatchFetchResult { * Represents a successful result of a batch fetch request. * * @param data fetched data. + * @param empty indicates that data is empty and [BatchListSource] shouldn't create new batch for this result * @param last indicates if this is the last batch for the request. */ data class Success( val data: TData, + val empty: Boolean, val last: Boolean, ) : BatchFetchResult() diff --git a/core/pagination/src/main/java/com/tangem/pagination/BatchListSource.kt b/core/pagination/src/main/java/com/tangem/pagination/BatchListSource.kt index d286dafab8..158de617e7 100644 --- a/core/pagination/src/main/java/com/tangem/pagination/BatchListSource.kt +++ b/core/pagination/src/main/java/com/tangem/pagination/BatchListSource.kt @@ -92,11 +92,7 @@ private class DefaultBatchListSource awaitCancellation() } finally { withContext(NonCancellable) { - stopAllUpdates() - loadMoreActionJob = null - loadMoreActionJob = null - lastRequestResult.value = null - state.value = BatchListState(emptyList(), PaginationStatus.None) + resetState() } } } @@ -110,6 +106,7 @@ private class DefaultBatchListSource } } + @Suppress("CyclomaticComplexMethod") private fun collectActions(action: BatchAction) { when (action) { is BatchAction.Reload -> { @@ -165,6 +162,9 @@ private class DefaultBatchListSource loadMoreActionJob?.cancel() reloadActionJob?.cancel() } + BatchAction.Reset -> { + resetState() + } } } @@ -237,13 +237,17 @@ private class DefaultBatchListSource state.value = when (res) { is BatchFetchResult.Success -> { - val key = generateNewKey(listOf()) - val batch = Batch( - key = key, - data = res.data, - ) + val batch = if (res.empty.not()) { + Batch( + key = generateNewKey(listOf()), + data = res.data, + ) + } else { + null + } + BatchListState( - data = listOf(batch), + data = batch?.let { listOf(it) } ?: emptyList(), status = if (res.last) { PaginationStatus.EndOfPagination } else { @@ -289,13 +293,17 @@ private class DefaultBatchListSource state.update { currentState -> when (res) { is BatchFetchResult.Success -> { - val newBatch = Batch( - key = generateNewKey(currentState.data.map { it.key }), - data = res.data, - ) + val newBatch = if (res.empty.not()) { + Batch( + key = generateNewKey(currentState.data.map { it.key }), + data = res.data, + ) + } else { + null + } currentState.copy( - data = currentState.data + newBatch, + data = newBatch?.let { currentState.data + it } ?: currentState.data, status = if (res.last) { PaginationStatus.EndOfPagination } else { @@ -400,6 +408,18 @@ private class DefaultBatchListSource waitingUpdateJobs.value.any { it.first.operationId == operationId } } + private fun resetState() { + if (updateFetcher != null) { + stopAllUpdates() + } + loadMoreActionJob?.cancel() + loadMoreActionJob = null + reloadActionJob?.cancel() + reloadActionJob = null + lastRequestResult.value = null + state.value = BatchListState(emptyList(), PaginationStatus.None) + } + private fun stopAllUpdates() { updateAsyncJobs.update { actionAsyncJobs -> actionAsyncJobs.forEach { diff --git a/core/pagination/src/main/java/com/tangem/pagination/fetcher/LimitOffsetBatchFetcher.kt b/core/pagination/src/main/java/com/tangem/pagination/fetcher/LimitOffsetBatchFetcher.kt index de125a62ff..73a2c5f0a7 100644 --- a/core/pagination/src/main/java/com/tangem/pagination/fetcher/LimitOffsetBatchFetcher.kt +++ b/core/pagination/src/main/java/com/tangem/pagination/fetcher/LimitOffsetBatchFetcher.kt @@ -32,6 +32,7 @@ class LimitOffsetBatchFetcher( suspend fun fetch( request: Request, lastResult: BatchFetchResult?, + isFirstBatchFetching: Boolean, ): BatchFetchResult } @@ -45,7 +46,7 @@ class LimitOffsetBatchFetcher( ) val res = runCatching { - subFetcher.fetch(req, null) + subFetcher.fetch(request = req, lastResult = null, isFirstBatchFetching = true) }.getOrElse { currentCoroutineContext().ensureActive() BatchFetchResult.Error(it) @@ -77,7 +78,7 @@ class LimitOffsetBatchFetcher( } val res = runCatching { - subFetcher.fetch(req, lastResult) + subFetcher.fetch(request = req, lastResult = lastResult, isFirstBatchFetching = false) }.getOrElse { currentCoroutineContext().ensureActive() BatchFetchResult.Error(it) diff --git a/core/res/src/main/res/values-ru/strings.xml b/core/res/src/main/res/values-ru/strings.xml index 14c45c2681..fba85fe955 100644 --- a/core/res/src/main/res/values-ru/strings.xml +++ b/core/res/src/main/res/values-ru/strings.xml @@ -68,6 +68,7 @@ Недостаточно ADA Принять Доступ запрещен + Разрешить Применить Одобрение Подтвердить diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index c1a5d5ad43..2c36f14fdf 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -337,7 +337,12 @@ My portfolio Market To generate addresses for selected networks, you need to attach a Tangem card + Unable to load the data… Quick actions + Result + See tokens under 100k market cap + Show tokens + No result Select network Select wallet 1m @@ -722,6 +727,7 @@ Operation from: %s to: %s + Try again You\'ve scanned the same card. To create a twin wallet you need to scan the card with number %d You\'ve scanned wrong twin card. Please try another one This one that you are holding in your hands and the other one with number %s.\n\nBoth cards can be used to extract funds from this wallet. diff --git a/data/markets/src/main/java/com/tangem/data/markets/DefaultMarketsTokenRepository.kt b/data/markets/src/main/java/com/tangem/data/markets/DefaultMarketsTokenRepository.kt index a0b50723dc..be83e51fa2 100644 --- a/data/markets/src/main/java/com/tangem/data/markets/DefaultMarketsTokenRepository.kt +++ b/data/markets/src/main/java/com/tangem/data/markets/DefaultMarketsTokenRepository.kt @@ -10,7 +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 kotlinx.coroutines.* +import java.util.concurrent.atomic.AtomicLong internal class DefaultMarketsTokenRepository( private val marketsApi: TangemTechMarketsApi, @@ -20,42 +20,61 @@ internal class DefaultMarketsTokenRepository( private val tokenListConverter = TokenMarketListConverter() - private val tokenMarketsFetcher - get() = LimitOffsetBatchFetcher( - prefetchDistance = 150, - batchSize = 100, - subFetcher = object : LimitOffsetBatchFetcher.SubFetcher> { + private fun createTokenMarketsFetcher(firstBatchSize: Int, nextBatchSize: Int) = LimitOffsetBatchFetcher( + prefetchDistance = firstBatchSize, + batchSize = nextBatchSize, + subFetcher = object : LimitOffsetBatchFetcher.SubFetcher> { - var requestTimeStamp: Long? = null // TODO when backend is ready + val requestTimeStamp = AtomicLong(0) - override suspend fun fetch( - request: LimitOffsetBatchFetcher.Request, - lastResult: BatchFetchResult>?, - ): BatchFetchResult> { - val res = retryOnError(priority = true) { - marketsApi.getCoinsList( - currency = request.params.fiatPriceCurrency, - interval = request.params.priceChangeInterval.toRequestParam(), - order = request.params.order.toRequestParam(), - search = request.params.searchText, - generalCoins = request.params.showUnder100kMarketCapTokens.not(), - offset = request.offset, - limit = request.limit, - ).getOrThrow() - } + override suspend fun fetch( + request: LimitOffsetBatchFetcher.Request, + lastResult: BatchFetchResult>?, + isFirstBatchFetching: Boolean, + ): BatchFetchResult> { + val searchText = + if (request.params.searchText.isNullOrBlank()) null else request.params.searchText - val last = res.tokens.size < request.limit - - return BatchFetchResult.Success( - data = tokenListConverter.convert(res), - last = last, - ) + val requestCall = suspend { + marketsApi.getCoinsList( + currency = request.params.fiatPriceCurrency, + interval = request.params.priceChangeInterval.toRequestParam(), + order = request.params.order.toRequestParam(), + search = searchText, + generalCoins = request.params.showUnder100kMarketCapTokens.not(), + offset = request.offset, + limit = request.limit, + ).getOrThrow() } - }, - ) + + // we shouldn't infinitely retry on the first batch request + val res = if (isFirstBatchFetching) { + requestCall() + } else { + retryOnError(priority = true) { + requestCall() + } + } + + if (isFirstBatchFetching) { + requestTimeStamp.set(0) // TODO when backend is ready + } + + val last = res.tokens.size < request.limit + + return BatchFetchResult.Success( + data = tokenListConverter.convert(res), + last = last, + empty = res.tokens.isEmpty(), + ) + } + }, + ) override fun getTokenListFlow( batchingContext: BatchingContext, + firstBatchSize: Int, + nextBatchSize: Int, ): BatchFlow, TokenMarketUpdateRequest> { val tokenMarketsUpdateFetcher = MarketsBatchUpdateFetcher( tangemTechApi = tangemTechApi, @@ -66,7 +85,7 @@ internal class DefaultMarketsTokenRepository( fetchDispatcher = dispatcherProvider.io, context = batchingContext, generateNewKey = { it.size }, - batchFetcher = tokenMarketsFetcher, + batchFetcher = createTokenMarketsFetcher(firstBatchSize = firstBatchSize, nextBatchSize = nextBatchSize), updateFetcher = tokenMarketsUpdateFetcher, ).toBatchFlow() } diff --git a/data/markets/src/main/java/com/tangem/data/markets/converters/TokenMarketListConverter.kt b/data/markets/src/main/java/com/tangem/data/markets/converters/TokenMarketListConverter.kt index 8f9c571a86..96f7e01ebf 100644 --- a/data/markets/src/main/java/com/tangem/data/markets/converters/TokenMarketListConverter.kt +++ b/data/markets/src/main/java/com/tangem/data/markets/converters/TokenMarketListConverter.kt @@ -9,6 +9,14 @@ import com.tangem.utils.converter.Converter class TokenMarketListConverter : Converter> { override fun convert(value: TokenMarketListResponse): List { + val imageHost = value.imageHost ?: run { + if (value.tokens.isEmpty()) { + return emptyList() + } else { + error("imageHost cannot be null") + } + } + return value.tokens.map { token -> TokenMarket( id = token.id, @@ -16,7 +24,7 @@ class TokenMarketListConverter : Converter, TokenMarketUpda class GetMarketsTokenListFlowUseCase( private val marketsTokenRepository: MarketsTokenRepository, ) { - operator fun invoke(batchingContext: TokenListBatchingContext): TokenListBatchFlow { - return marketsTokenRepository.getTokenListFlow(batchingContext) + operator fun invoke(batchingContext: TokenListBatchingContext, batchFlowType: BatchFlowType): TokenListBatchFlow { + return marketsTokenRepository.getTokenListFlow( + batchingContext = batchingContext, + firstBatchSize = batchFlowType.firstBatchSize, + nextBatchSize = batchFlowType.nextBatchSize, + ) + } + + enum class BatchFlowType( + val firstBatchSize: Int, + val nextBatchSize: Int, + ) { + Main(firstBatchSize = 150, nextBatchSize = 100), + Search(firstBatchSize = 50, nextBatchSize = 50), } } \ No newline at end of file diff --git a/domain/markets/src/main/java/com/tangem/domain/markets/repositories/MarketsTokenRepository.kt b/domain/markets/src/main/java/com/tangem/domain/markets/repositories/MarketsTokenRepository.kt index b72061f9f2..31693705dc 100644 --- a/domain/markets/src/main/java/com/tangem/domain/markets/repositories/MarketsTokenRepository.kt +++ b/domain/markets/src/main/java/com/tangem/domain/markets/repositories/MarketsTokenRepository.kt @@ -4,5 +4,9 @@ import com.tangem.domain.markets.* interface MarketsTokenRepository { - fun getTokenListFlow(batchingContext: TokenListBatchingContext): TokenListBatchFlow + fun getTokenListFlow( + batchingContext: TokenListBatchingContext, + firstBatchSize: Int, + nextBatchSize: Int, + ): TokenListBatchFlow } \ No newline at end of file diff --git a/features/markets/impl/build.gradle.kts b/features/markets/impl/build.gradle.kts index 201feb6d22..46bc8c674a 100644 --- a/features/markets/impl/build.gradle.kts +++ b/features/markets/impl/build.gradle.kts @@ -28,6 +28,7 @@ dependencies { implementation(deps.compose.ui.tooling) implementation(deps.compose.ui.utils) implementation(deps.lifecycle.compose) + implementation(deps.androidx.activity.compose) /* DI */ implementation(deps.hilt.android) diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/component/impl/DefaultMarketsListComponent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/component/impl/DefaultMarketsListComponent.kt index 55757a3a42..c5329d71a3 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/component/impl/DefaultMarketsListComponent.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/component/impl/DefaultMarketsListComponent.kt @@ -30,15 +30,17 @@ internal class DefaultMarketsListComponent @AssistedInject constructor( modifier: Modifier, ) { val state by model.state.collectAsStateWithLifecycle() + val bsState by bottomSheetState - LaunchedEffect(bottomSheetState.value) { - model.containerBottomSheetState.value = bottomSheetState.value + LaunchedEffect(bsState) { + model.containerBottomSheetState.value = bsState } MarketsList( - onHeaderSizeChange = onHeaderSizeChange, - state = state, modifier = modifier, + state = state, + onHeaderSizeChange = onHeaderSizeChange, + bottomSheetState = bsState, ) } diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/MarketsListModel.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/MarketsListModel.kt index b0d1dac2b3..97fc37c161 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/MarketsListModel.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/MarketsListModel.kt @@ -9,21 +9,21 @@ import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.GetMarketsTokenListFlowUseCase import com.tangem.features.markets.component.BottomSheetState import com.tangem.features.markets.model.statemanager.MarketsListUMStateManager -import com.tangem.features.markets.model.statemanager.MarketsListUiItemsManager +import com.tangem.features.markets.model.statemanager.MarketsListBatchFlowManager import com.tangem.features.markets.ui.entity.ListUM import com.tangem.features.markets.ui.entity.SortByTypeUM 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.coroutines.CoroutineScope -import kotlinx.coroutines.delay +import kotlinx.coroutines.* import kotlinx.coroutines.flow.* -import kotlinx.coroutines.launch import javax.inject.Inject private const val UPDATE_QUOTES_TIMER_MILLIS = 60000L +private const val SEARCH_QUERY_DEBOUNCE_MILLIS = 800L +@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) @ComponentScoped @Stable internal class MarketsListModel @Inject constructor( @@ -42,38 +42,65 @@ internal class MarketsListModel @Inject constructor( ) private val visibleItemIds = MutableStateFlow>(emptyList()) - val containerBottomSheetState = MutableStateFlow(BottomSheetState.COLLAPSED) + private val marketsListUMStateManager = MarketsListUMStateManager( onLoadMoreUiItems = { activeListManager.loadMore() }, visibleItemsChanged = { visibleItemIds.value = it }, + onRetryButtonClicked = { activeListManager.reload() }, ) - private val marketsListManager = MarketsListUiItemsManager( - logTag = "main", + private val mainMarketsListManager = MarketsListBatchFlowManager( getMarketsTokenListFlowUseCase = getMarketsTokenListFlowUseCase, + batchFlowType = GetMarketsTokenListFlowUseCase.BatchFlowType.Main, currentAppCurrency = Provider { currentAppCurrency.value }, currentTrendInterval = Provider { marketsListUMStateManager.selectedInterval }, + currentSortByType = Provider { marketsListUMStateManager.selectedSortByType }, + currentSearchText = Provider { null }, modelScope = modelScope, dispatchers = dispatchers, ) - private val searchMarketsListManager = MarketsListUiItemsManager( - logTag = "search", + private val searchMarketsListManager = MarketsListBatchFlowManager( getMarketsTokenListFlowUseCase = getMarketsTokenListFlowUseCase, + batchFlowType = GetMarketsTokenListFlowUseCase.BatchFlowType.Search, currentAppCurrency = Provider { currentAppCurrency.value }, - currentTrendInterval = Provider { marketsListUMStateManager.selectedInterval }, + currentTrendInterval = Provider { marketsListUMStateManager.selectedInterval }, // FIXME fix on backend + currentSortByType = Provider { SortByTypeUM.Rating }, // FIXME maybe fix on backend + currentSearchText = Provider { marketsListUMStateManager.searchQuery }, modelScope = modelScope, dispatchers = dispatchers, ) - private var activeListManager: MarketsListUiItemsManager = marketsListManager + private var activeListManager: MarketsListBatchFlowManager = mainMarketsListManager + + val containerBottomSheetState = MutableStateFlow(BottomSheetState.COLLAPSED) val state = marketsListUMStateManager.state.asStateFlow() init { + @Suppress("UnnecessaryParentheses") modelScope.launch { - marketsListManager.uiItems - .collectLatest { - marketsListUMStateManager.onUiItemsChanged(it) + marketsListUMStateManager.isInSearchStateFlow + .flatMapLatest { isInSearchMode -> + if (isInSearchMode) { + combine( + searchMarketsListManager.uiItems, + searchMarketsListManager.isInInitialLoadingErrorState, + searchMarketsListManager.isSearchNotFoundState, + ) { items, isError, notFound -> + (items to isError) to notFound + } + } else { + combine( + mainMarketsListManager.uiItems, + mainMarketsListManager.isInInitialLoadingErrorState, + ) { items, isError -> (items to isError) to false } + } + }.collect { + marketsListUMStateManager.onUiItemsChanged( + uiItems = it.first.first, + isInErrorState = it.first.second, + isSearchNotFound = it.second, + ) } } @@ -87,23 +114,16 @@ internal class MarketsListModel @Inject constructor( currentAppCurrency .drop(1) .onEach { - marketsListManager.reload( - interval = marketsListUMStateManager.selectedInterval, - sortBy = marketsListUMStateManager.selectedSortByType, - ) + mainMarketsListManager.reload() if (marketsListUMStateManager.isInSearchState) { - // TODO - searchMarketsListManager.reload( - interval = marketsListUMStateManager.selectedInterval, - sortBy = marketsListUMStateManager.selectedSortByType, - ) + searchMarketsListManager.reload() } }.launchIn(modelScope) // load charts when new batch is being loaded - marketsListManager.onLastBatchLoadedSuccess + mainMarketsListManager.onLastBatchLoadedSuccess .onEach { - marketsListManager.loadCharts(setOf(it), marketsListUMStateManager.selectedInterval) + mainMarketsListManager.loadCharts(setOf(it), marketsListUMStateManager.selectedInterval) modelScope.loadQuotesWithTimer(timeMillis = UPDATE_QUOTES_TIMER_MILLIS) } .launchIn(modelScope) @@ -117,11 +137,11 @@ internal class MarketsListModel @Inject constructor( .collectLatest { interval -> when (marketsListUMStateManager.selectedSortByType) { SortByTypeUM.Rating -> { - marketsListManager.updateUIWithSameState() - val batchKeys = marketsListManager.getBatchKeysByItemIds(visibleItemIds.value) - marketsListManager.loadCharts(batchKeys, interval) + mainMarketsListManager.updateUIWithSameState() + val batchKeys = mainMarketsListManager.getBatchKeysByItemIds(visibleItemIds.value) + mainMarketsListManager.loadCharts(batchKeys, interval) } - else -> marketsListManager.reload(interval, marketsListUMStateManager.selectedSortByType) + else -> mainMarketsListManager.reload() } } } @@ -133,12 +153,12 @@ internal class MarketsListModel @Inject constructor( .distinctUntilChanged() .drop(1) .collectLatest { - marketsListManager.reload(marketsListUMStateManager.selectedInterval, it) + mainMarketsListManager.reload() } } // listen current visible batch and update charts - modelScope.launch(dispatchers.default) { + modelScope.launch { visibleItemIds .mapNotNull { if (it.isNotEmpty()) { @@ -153,11 +173,41 @@ internal class MarketsListModel @Inject constructor( } } + // ===Search=== + + modelScope.launch { + marketsListUMStateManager.isInSearchStateFlow + .collectLatest { isInSearchMode -> + activeListManager = if (isInSearchMode) { + searchMarketsListManager + } else { + searchMarketsListManager.clearStateAndStopAllActions() + mainMarketsListManager + } + } + } + + modelScope.launch { + marketsListUMStateManager.searchQueryFlow + .filter { it.isNotEmpty() } + .debounce(timeoutMillis = SEARCH_QUERY_DEBOUNCE_MILLIS) + .filter { activeListManager == searchMarketsListManager } + .collectLatest { + searchMarketsListManager.reload(searchText = it) + } + } + + modelScope.launch { + searchMarketsListManager + .onLastBatchLoadedSuccess + .collectLatest { + searchMarketsListManager.loadCharts(setOf(it), marketsListUMStateManager.selectedInterval) + modelScope.loadQuotesWithTimer(timeMillis = UPDATE_QUOTES_TIMER_MILLIS) + } + } + // initial loading - marketsListManager.reload( - interval = marketsListUMStateManager.selectedInterval, - sortBy = marketsListUMStateManager.selectedSortByType, - ) + mainMarketsListManager.reload() } private var updateQuotesJob = JobHolder() @@ -167,7 +217,7 @@ 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() + activeListManager.updateQuotes() // TODO update a batch that is currently on screen } }.saveIn(updateQuotesJob) } diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/converters/MarketsTokenItemConverter.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/converters/MarketsTokenItemConverter.kt index 6366b68d38..c2badcd569 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/converters/MarketsTokenItemConverter.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/converters/MarketsTokenItemConverter.kt @@ -30,6 +30,7 @@ internal class MarketsTokenItemConverter( trendPercentText = value.getTrendPercent(), trendType = value.getTrendType(), chardData = value.getChartData(), + showUnder100kMarketCap = value.isUnder100kMarketCap(), ) } @@ -76,7 +77,7 @@ internal class MarketsTokenItemConverter( private fun TokenMarket.getCurrentPrice(prev: TokenMarket? = null): MarketsListItemUM.Price { val prevPrice = prev?.tokenQuotes?.currentPrice - val priceText = BigDecimalFormatter.formatFiatAmount( + val priceText = BigDecimalFormatter.formatFiatAmountUncapped( fiatAmount = tokenQuotes.currentPrice, fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol, @@ -141,4 +142,12 @@ internal class MarketsTokenItemConverter( useAbsoluteValue = true, ) } + + private fun TokenMarket.isUnder100kMarketCap(): Boolean { + return tokenQuotes.currentPrice.compareTo(decimal100k) == -1 + } + + private companion object { + val decimal100k: BigDecimal = BigDecimal.valueOf(100_000) + } } \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/statemanager/MarketsListUiItemsManager.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/statemanager/MarketsListBatchFlowManager.kt similarity index 81% rename from features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/statemanager/MarketsListUiItemsManager.kt rename to features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/statemanager/MarketsListBatchFlowManager.kt index 53147eb848..ae897a5b9a 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/statemanager/MarketsListUiItemsManager.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/statemanager/MarketsListBatchFlowManager.kt @@ -4,6 +4,7 @@ import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.* import com.tangem.features.markets.model.converters.MarketsTokenItemConverter import com.tangem.features.markets.model.utils.logAction +import com.tangem.features.markets.model.utils.logStatus import com.tangem.features.markets.model.utils.logUpdateResults import com.tangem.features.markets.ui.entity.MarketsListItemUM import com.tangem.features.markets.ui.entity.MarketsListUM.TrendInterval @@ -18,23 +19,27 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch -private const val LOG_EVENTS = false +private const val LOG_EVENTS = true -internal class MarketsListUiItemsManager( - private val logTag: String = "main", +@Suppress("LongParameterList") +internal class MarketsListBatchFlowManager( getMarketsTokenListFlowUseCase: GetMarketsTokenListFlowUseCase, + private val batchFlowType: GetMarketsTokenListFlowUseCase.BatchFlowType, private val currentTrendInterval: Provider, private val currentAppCurrency: Provider, + private val currentSearchText: Provider, + private val currentSortByType: Provider, private val modelScope: CoroutineScope, private val dispatchers: CoroutineDispatcherProvider, ) { private val actionsFlow = MutableSharedFlow>() private val batchFlow = getMarketsTokenListFlowUseCase( - TokenListBatchingContext( + batchingContext = TokenListBatchingContext( actionsFlow = actionsFlow, coroutineScope = modelScope, ), + batchFlowType = batchFlowType, ) val uiItems: StateFlow> @@ -53,7 +58,7 @@ internal class MarketsListUiItemsManager( ) val onLastBatchLoadedSuccess = batchFlow.state - .distinctUntilChanged { old, new -> old.status === new.status } + .distinctUntilChanged { old, new -> old.status == new.status && old.data.size == new.data.size } .mapNotNull { when (val status = it.status) { is PaginationStatus.Paginating -> { @@ -70,6 +75,28 @@ internal class MarketsListUiItemsManager( } } + val isInInitialLoadingErrorState = batchFlow.state + .map { it.status is PaginationStatus.InitialLoadingError } + .distinctUntilChanged() + .stateIn( + scope = modelScope, + started = SharingStarted.Eagerly, + initialValue = false, + ) + + val isSearchNotFoundState = batchFlow.state + .map { + currentSearchText().isNullOrEmpty().not() && + it.status is PaginationStatus.EndOfPagination && + it.data.isEmpty() + } + .distinctUntilChanged() + .stateIn( + scope = modelScope, + started = SharingStarted.Eagerly, + initialValue = false, + ) + private val uiBatches = MutableStateFlow>>>(emptyList()) init { @@ -86,11 +113,16 @@ internal class MarketsListUiItemsManager( if (LOG_EVENTS) { batchFlow.updateResults - .onEach { logUpdateResults(logTag, it) } + .onEach { logUpdateResults(batchFlowType.name, it) } + .launchIn(modelScope) + + batchFlow.state + .map { it.status } + .onEach { logStatus(batchFlowType.name, it) } .launchIn(modelScope) actionsFlow - .onEach { logAction(logTag, it) } + .onEach { logAction(batchFlowType.name, it) } .launchIn(modelScope) } } @@ -144,17 +176,21 @@ internal class MarketsListUiItemsManager( } } - fun reload(interval: TrendInterval, sortBy: SortByTypeUM) { + fun reload(searchText: String? = null) { modelScope.launch { uiBatches.value = emptyList() actionsFlow.emit( BatchAction.Reload( requestParams = TokenMarketListConfig( fiatPriceCurrency = currentAppCurrency().code, - searchText = null, - showUnder100kMarketCapTokens = false, - priceChangeInterval = interval.toBatchRequestInterval(), - order = sortBy.toRequestOrder(), + searchText = if (currentSearchText() == null) { + null + } else { + searchText ?: currentSearchText() + }, + showUnder100kMarketCapTokens = false, // TODO + priceChangeInterval = currentTrendInterval().toBatchRequestInterval(), + order = currentSortByType().toRequestOrder(), ), ), ) @@ -229,6 +265,13 @@ internal class MarketsListUiItemsManager( } } + fun clearStateAndStopAllActions() { + uiBatches.value = emptyList() + modelScope.launch { + actionsFlow.emit(BatchAction.Reset) + } + } + fun getBatchKeysByItemIds(ids: List): Set { val currentData = batchFlow.state.value.data diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/statemanager/MarketsListUMStateManager.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/statemanager/MarketsListUMStateManager.kt index 052239a869..d845850f44 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/statemanager/MarketsListUMStateManager.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/statemanager/MarketsListUMStateManager.kt @@ -3,32 +3,43 @@ package com.tangem.features.markets.model.statemanager import androidx.compose.runtime.Stable import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.fields.entity.SearchBarUM +import com.tangem.core.ui.event.consumedEvent +import com.tangem.core.ui.event.triggeredEvent import com.tangem.core.ui.extensions.resourceReference import com.tangem.features.markets.impl.R -import com.tangem.features.markets.model.SortByBottomSheetContentUM +import com.tangem.features.markets.ui.entity.SortByBottomSheetContentUM import com.tangem.features.markets.ui.entity.ListUM import com.tangem.features.markets.ui.entity.MarketsListItemUM import com.tangem.features.markets.ui.entity.MarketsListUM import com.tangem.features.markets.ui.entity.SortByTypeUM import kotlinx.collections.immutable.ImmutableList -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.* @Stable internal class MarketsListUMStateManager( private val onLoadMoreUiItems: () -> Unit, private val visibleItemsChanged: (itemsKeys: List) -> Unit, + private val onRetryButtonClicked: () -> Unit, ) { private var sortByBottomSheetIsShown get() = state.value.sortByBottomSheet.isShow set(value) = state.update { it.copy(sortByBottomSheet = it.sortByBottomSheet.copy(isShow = value)) } - private val isInSearchStateFlow = MutableStateFlow(false) + var searchQuery + get() = state.value.searchBar.query + private set(value) = state.update { + it.copy( + searchBar = it.searchBar.copy( + query = value, + isActive = value.isNotEmpty(), + ), + ) + } var isInSearchState - get() = isInSearchStateFlow.value - set(value) { isInSearchStateFlow.value = value } + get() = state.value.searchBar.isActive + private set(value) = state.update { it.copy(searchBar = it.searchBar.copy(isActive = value)) } var selectedSortByType get() = state.value.selectedSortBy @@ -40,29 +51,64 @@ internal class MarketsListUMStateManager( selectedOption = value, ), ), + list = if (it.list is ListUM.Content && it.selectedSortBy != value) { + it.list.copy(triggerScrollReset = triggeredEvent(Unit) { consumeTriggerResetScrollEvent() }) + } else { + it.list + }, ) } var selectedInterval get() = state.value.selectedInterval - set(value) = state.update { it.copy(selectedInterval = value) } + set(value) = state.update { + it.copy( + selectedInterval = value, + list = if (it.list is ListUM.Content && + it.selectedSortBy != SortByTypeUM.Rating && + it.selectedInterval != value + ) { + it.list.copy(triggerScrollReset = triggeredEvent(Unit) { consumeTriggerResetScrollEvent() }) + } else { + it.list + }, + ) + } val state = MutableStateFlow(state()) + val isInSearchStateFlow = state.map { it.searchBar.isActive }.distinctUntilChanged() + val searchQueryFlow = state.map { it.searchBar.query }.distinctUntilChanged() - fun onUiItemsChanged(uiItems: ImmutableList) { + fun onUiItemsChanged( + isInErrorState: Boolean, + isSearchNotFound: Boolean, + uiItems: ImmutableList, + ) { state.update { - if (uiItems.isEmpty()) { - it.copy( - list = ListUM.Loading, - ) - } else { - it.copy( - list = ListUM.Content( - items = uiItems, - loadMore = onLoadMoreUiItems, - visibleIdsChanged = visibleItemsChanged, - ), - ) + when { + isInErrorState -> { + it.copy( + list = ListUM.LoadingError(onRetryClicked = onRetryButtonClicked), + ) + } + isSearchNotFound -> { + it.copy(list = ListUM.SearchNothingFound) + } + uiItems.isEmpty() -> { + it.copy(list = ListUM.Loading) + } + else -> { + it.copy( + list = ListUM.Content( + items = uiItems, + loadMore = onLoadMoreUiItems, + visibleIdsChanged = visibleItemsChanged, + showUnder100kTokens = true, + onShowTokensUnder100kClicked = { }, + triggerScrollReset = consumedEvent(), + ), + ) + } } } } @@ -71,10 +117,10 @@ internal class MarketsListUMStateManager( list = ListUM.Loading, searchBar = SearchBarUM( placeholderText = resourceReference(R.string.manage_tokens_search_placeholder), - query = "", // TODO - onQueryChange = {}, // TODO - isActive = false, // TODO - onActiveChange = { }, // TODO + query = "", + onQueryChange = { searchQuery = it }, + isActive = false, + onActiveChange = { }, ), selectedSortBy = SortByTypeUM.Rating, selectedInterval = MarketsListUM.TrendInterval.H24, @@ -103,4 +149,18 @@ internal class MarketsListUMStateManager( ) } } + + private fun consumeTriggerResetScrollEvent() { + state.update { + it.copy( + list = if (it.list is ListUM.Content) { + it.list.copy( + triggerScrollReset = consumedEvent(), + ) + } else { + it.list + }, + ) + } + } } \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/utils/LoggingUtils.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/utils/LoggingUtils.kt index a7594edf81..e9cc0e8b31 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/utils/LoggingUtils.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/utils/LoggingUtils.kt @@ -5,8 +5,18 @@ import com.tangem.domain.markets.TokenMarketListConfig import com.tangem.domain.markets.TokenMarketUpdateRequest import com.tangem.pagination.BatchAction import com.tangem.pagination.BatchUpdateResult +import com.tangem.pagination.PaginationStatus import timber.log.Timber +internal fun logStatus(tag: String, status: PaginationStatus>) { + Timber.tag(tag).d( + """ + Status + $status + """.trimIndent(), + ) +} + internal fun logAction(tag: String, action: BatchAction) { when (action) { is BatchAction.Reload -> Timber.tag(tag).d( diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/MarketsList.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/MarketsList.kt index 4f61361468..3ff1f1d4e7 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/MarketsList.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/MarketsList.kt @@ -1,25 +1,25 @@ package com.tangem.features.markets.ui +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.Text -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.input.nestedscroll.NestedScrollConnection -import androidx.compose.ui.input.nestedscroll.NestedScrollSource -import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.Keyboard import com.tangem.core.ui.components.SpacerH12 import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.buttons.SecondarySmallButton @@ -28,31 +28,41 @@ import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition import com.tangem.core.ui.components.buttons.segmentedbutton.SegmentedButtons import com.tangem.core.ui.components.fields.SearchBar import com.tangem.core.ui.components.fields.entity.SearchBarUM +import com.tangem.core.ui.components.keyboardAsState +import com.tangem.core.ui.event.consumedEvent import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.markets.component.BottomSheetState import com.tangem.features.markets.impl.R -import com.tangem.features.markets.model.SortByBottomSheetContentUM -import com.tangem.features.markets.ui.components.MarketsListItem -import com.tangem.features.markets.ui.components.MarketsListItemPlaceholder +import com.tangem.features.markets.ui.components.MarketsListLazyColumn import com.tangem.features.markets.ui.components.MarketsListSortByBottomSheet import com.tangem.features.markets.ui.entity.ListUM import com.tangem.features.markets.ui.entity.MarketsListUM +import com.tangem.features.markets.ui.entity.SortByBottomSheetContentUM import com.tangem.features.markets.ui.entity.SortByTypeUM import com.tangem.features.markets.ui.preview.MarketChartListItemPreviewDataProvider import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @Composable -internal fun MarketsList(state: MarketsListUM, onHeaderSizeChange: (Dp) -> Unit, modifier: Modifier = Modifier) { +internal fun MarketsList( + state: MarketsListUM, + onHeaderSizeChange: (Dp) -> Unit, + bottomSheetState: BottomSheetState, + modifier: Modifier = Modifier, +) { Content( modifier = modifier, state = state, onHeaderSizeChange = onHeaderSizeChange, ) - MarketsListSortByBottomSheet(config = state.sortByBottomSheet) + KeyboardEvents( + isSortByBottomSheetShown = state.sortByBottomSheet.isShow, + bottomSheetState = bottomSheetState, + ) } @Composable @@ -66,7 +76,6 @@ private fun Content(state: MarketsListUM, onHeaderSizeChange: (Dp) -> Unit, modi .background(color = TangemTheme.colors.background.primary), ) { SearchBar( - state = state.searchBar, modifier = Modifier .background(color = TangemTheme.colors.background.primary) .padding( @@ -77,28 +86,40 @@ private fun Content(state: MarketsListUM, onHeaderSizeChange: (Dp) -> Unit, modi .onGloballyPositioned { with(density) { onHeaderSizeChange(it.size.height.toDp()) } }, + state = state.searchBar, ) Spacer(Modifier.height(TangemTheme.dimens.spacing20)) Column(Modifier.padding(horizontal = TangemTheme.dimens.size16)) { - Title() - SpacerH12() - Options( - sortByTypeUM = state.selectedSortBy, - trendInterval = state.selectedInterval, - onIntervalClick = state.onIntervalClick, - onSortByClick = state.onSortByButtonClick, - ) + Title(isInSearchMode = state.isInSearchMode) + AnimatedVisibility(state.isInSearchMode.not()) { + Column { + SpacerH12() + Options( + sortByTypeUM = state.selectedSortBy, + trendInterval = state.selectedInterval, + onIntervalClick = state.onIntervalClick, + onSortByClick = state.onSortByButtonClick, + ) + } + } } SpacerH12() - Items(state = state.list) + ItemsList( + isInSearchMode = state.isInSearchMode, + state = state.list, + ) } } @Composable -private fun Title(modifier: Modifier = Modifier) { +private fun Title(isInSearchMode: Boolean, modifier: Modifier = Modifier) { Text( modifier = modifier, - text = stringResource(id = R.string.markets_common_title), + text = if (isInSearchMode) { + stringResource(id = R.string.markets_search_result_title) + } else { + stringResource(id = R.string.markets_common_title) + }, style = TangemTheme.typography.h3, color = TangemTheme.colors.text.primary1, ) @@ -158,105 +179,49 @@ private fun Options( } @Composable -private fun Items(state: ListUM, modifier: Modifier = Modifier) { - val lazyListState = rememberLazyListState() - val scrollEnabled = state !is ListUM.Loading - val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() } +private fun ItemsList(isInSearchMode: Boolean, state: ListUM, modifier: Modifier = Modifier) { + val searchLazyListState = rememberLazyListState() + val mainLazyListState = rememberLazyListState() - LazyColumn( - modifier = modifier.nestedScroll(DisableParentConnection), - state = lazyListState, - contentPadding = PaddingValues(bottom = bottomBarHeight), - userScrollEnabled = scrollEnabled, - ) { - // ATTENTION! There should be no elements with a string key value except MarketsListItem! - when (state) { - ListUM.Loading -> { - items(count = 50, key = { it }) { - MarketsListItemPlaceholder() - } - } - ListUM.SearchNothingFound -> { - // TODO - } - is ListUM.Content -> { - items( - items = state.items, - key = { it.id }, - ) { item -> - MarketsListItem( - model = item, - ) - } - } - } - } - - LaunchedEffect(state) { - if (state is ListUM.Loading) { - lazyListState.scrollToItem(0) - } - } - - VisibleItemsTracker(lazyListState, state) - - InfiniteListHandler( - listState = lazyListState, - buffer = 50, - onLoadMore = remember(state) { - { - if (state is ListUM.Content) { - state.loadMore() - } - } + MarketsListLazyColumn( + modifier = modifier, + state = state, + isInSearchMode = isInSearchMode, + lazyListState = if (isInSearchMode) { + searchLazyListState + } else { + mainLazyListState }, ) } @Composable -fun VisibleItemsTracker(listState: LazyListState, state: ListUM) { - val visibleItems by remember { - derivedStateOf { - listState.layoutInfo.visibleItemsInfo.mapNotNull { it.key as? String } +private fun KeyboardEvents(isSortByBottomSheetShown: Boolean, bottomSheetState: BottomSheetState) { + val keyboardController = LocalSoftwareKeyboardController.current + val keyboard by keyboardAsState() + val focusManager = LocalFocusManager.current + + BackHandler(enabled = keyboard is Keyboard.Opened) { + keyboardController?.hide() + } + + LaunchedEffect(keyboard) { + if (keyboard is Keyboard.Closed) { + focusManager.clearFocus() } } - LaunchedEffect(listState.isScrollInProgress, visibleItems) { - if (state is ListUM.Content && listState.isScrollInProgress.not()) { - state.visibleIdsChanged(visibleItems) - } - } -} - -@Composable -fun InfiniteListHandler(listState: LazyListState, onLoadMore: () -> Unit, buffer: Int = 2) { - val loadMore by remember { - derivedStateOf { - val layoutInfo = listState.layoutInfo - val totalItemsNumber = layoutInfo.totalItemsCount - val lastVisibleItemIndex = (layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0) + 1 - - lastVisibleItemIndex > totalItemsNumber - buffer - } + LaunchedEffect(isSortByBottomSheetShown) { + keyboardController?.hide() } - val totalItemsCount by remember { derivedStateOf { listState.layoutInfo.totalItemsCount } } - var emitted by remember(totalItemsCount) { mutableStateOf(false) } - - LaunchedEffect(loadMore) { - if (loadMore && !emitted) { - emitted = true - onLoadMore() + LaunchedEffect(bottomSheetState) { + if (bottomSheetState == BottomSheetState.COLLAPSED) { + focusManager.clearFocus() } } } -private object DisableParentConnection : NestedScrollConnection { - override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset { - return available.copy(x = 0f) - } -} - //region: Preview @Preview @@ -272,8 +237,11 @@ private fun Preview() { item.copy(id = index.toString()) } .toImmutableList(), + showUnder100kTokens = false, loadMore = {}, visibleIdsChanged = {}, + onShowTokensUnder100kClicked = {}, + triggerScrollReset = consumedEvent(), ), searchBar = SearchBarUM( placeholderText = resourceReference(R.string.manage_tokens_search_placeholder), @@ -287,12 +255,13 @@ private fun Preview() { onIntervalClick = {}, onSortByButtonClick = {}, sortByBottomSheet = TangemBottomSheetConfig( - false, + isShow = false, onDismissRequest = {}, content = SortByBottomSheetContentUM(selectedOption = SortByTypeUM.Rating) {}, ), ), onHeaderSizeChange = {}, + bottomSheetState = BottomSheetState.EXPANDED, ) } } diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/components/MarketsListLazyColumn.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/components/MarketsListLazyColumn.kt new file mode 100644 index 0000000000..dd7753fb72 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/components/MarketsListLazyColumn.kt @@ -0,0 +1,222 @@ +package com.tangem.features.markets.ui.components + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import com.tangem.core.ui.components.buttons.SecondarySmallButton +import com.tangem.core.ui.components.buttons.SmallButtonConfig +import com.tangem.core.ui.event.EventEffect +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.markets.impl.R +import com.tangem.features.markets.ui.entity.ListUM +import kotlinx.coroutines.launch + +private const val LOAD_NEXT_PAGE_ON_END_INDEX = 50 + +@Composable +@Suppress("LongMethod") +internal fun MarketsListLazyColumn( + state: ListUM, + isInSearchMode: Boolean, + lazyListState: LazyListState, + modifier: Modifier = Modifier, +) { + val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() } + val coroutineScope = rememberCoroutineScope() + + SideEffect { + if (state is ListUM.Loading) { + coroutineScope.launch { + lazyListState.scrollToItem(0) + } + } + } + + if (state is ListUM.Content) { + EventEffect(state.triggerScrollReset) { + lazyListState.scrollToItem(0) + } + } + + if (state is ListUM.Loading) { + LazyColumn( + modifier = Modifier.nestedScroll(DisableParentConnection), + state = rememberLazyListState(), + contentPadding = PaddingValues(bottom = bottomBarHeight), + userScrollEnabled = false, + ) { + items(count = 100, key = { it }) { + MarketsListItemPlaceholder() + } + } + } else { + LazyColumn( + modifier = modifier.nestedScroll(DisableParentConnection), + state = lazyListState, + contentPadding = PaddingValues(bottom = bottomBarHeight), + userScrollEnabled = true, + ) { + // ATTENTION! There should be no elements with a string key value except MarketsListItem! + when (state) { + is ListUM.LoadingError -> { + item(key = "loading error".hashCode()) { + LoadingErrorItem( + modifier = Modifier.fillParentMaxSize(), + onTryAgain = state.onRetryClicked, + ) + } + } + ListUM.SearchNothingFound -> { + item(key = "not found text".hashCode()) { + SearchNothingFoundText( + modifier = Modifier.fillParentMaxSize(), + ) + } + } + is ListUM.Content -> { + items( + items = state.items, + key = { it.id }, + ) { item -> + MarketsListItem(model = item) + } + + if (isInSearchMode && state.showUnder100kTokens.not()) { + item(key = "show tokens under 100k".hashCode()) { + ShowTokensUnder100kItem( + onShowTokensClick = state.onShowTokensUnder100kClicked, + ) + } + } + } + else -> {} + } + } + } + + VisibleItemsTracker(lazyListState, state) + + InfiniteListHandler( + listState = lazyListState, + buffer = LOAD_NEXT_PAGE_ON_END_INDEX, + onLoadMore = remember(state) { + { + if (state is ListUM.Content) { + state.loadMore() + } + } + }, + ) +} + +@Composable +private fun LoadingErrorItem(onTryAgain: () -> Unit, modifier: Modifier = Modifier) { + Box( + modifier + .padding( + horizontal = TangemTheme.dimens.spacing16, + vertical = TangemTheme.dimens.spacing12, + ) + .fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + UnableToLoadData(onRetryClick = onTryAgain) + } +} + +@Composable +private fun ShowTokensUnder100kItem(onShowTokensClick: () -> Unit, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .padding( + horizontal = TangemTheme.dimens.spacing16, + vertical = TangemTheme.dimens.spacing12, + ) + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(R.string.markets_search_see_tokens_under_100k), + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.text.tertiary, + ) + SecondarySmallButton( + config = SmallButtonConfig( + text = resourceReference(R.string.markets_search_show_tokens), + onClick = onShowTokensClick, + ), + ) + } +} + +@Composable +private fun SearchNothingFoundText(modifier: Modifier = Modifier) { + Box( + modifier = modifier, + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(R.string.markets_search_token_no_result_title), + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.text.tertiary, + ) + } +} + +@Composable +private fun VisibleItemsTracker(listState: LazyListState, state: ListUM) { + val visibleItems by remember { + derivedStateOf { + listState.layoutInfo.visibleItemsInfo.mapNotNull { it.key as? String } + } + } + + LaunchedEffect(listState.isScrollInProgress, visibleItems) { + if (state is ListUM.Content && listState.isScrollInProgress.not()) { + state.visibleIdsChanged(visibleItems) + } + } +} + +@Composable +fun InfiniteListHandler(listState: LazyListState, onLoadMore: () -> Unit, buffer: Int = 2) { + val loadMore by remember { + derivedStateOf { + val layoutInfo = listState.layoutInfo + val totalItemsNumber = layoutInfo.totalItemsCount + val lastVisibleItemIndex = (layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0) + 1 + + lastVisibleItemIndex > totalItemsNumber - buffer + } + } + + val totalItemsCount by remember { derivedStateOf { listState.layoutInfo.totalItemsCount } } + var emitted by remember(totalItemsCount) { mutableStateOf(false) } + + LaunchedEffect(loadMore) { + if (loadMore && !emitted) { + emitted = true + onLoadMore() + } + } +} + +private object DisableParentConnection : NestedScrollConnection { + override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset { + return available.copy(x = 0f) + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/components/MarketsListSortByBottomSheet.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/components/MarketsListSortByBottomSheet.kt index ae2a87f4a0..4ee563d08c 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/components/MarketsListSortByBottomSheet.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/components/MarketsListSortByBottomSheet.kt @@ -19,7 +19,7 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.features.markets.impl.R -import com.tangem.features.markets.model.SortByBottomSheetContentUM +import com.tangem.features.markets.ui.entity.SortByBottomSheetContentUM import com.tangem.features.markets.ui.entity.SortByTypeUM @Composable diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/components/UnableToLoadData.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/components/UnableToLoadData.kt new file mode 100644 index 0000000000..cf58422fb5 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/components/UnableToLoadData.kt @@ -0,0 +1,47 @@ +package com.tangem.features.markets.ui.components + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import com.tangem.core.ui.components.buttons.SecondarySmallButton +import com.tangem.core.ui.components.buttons.SmallButtonConfig +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.markets.impl.R + +@Composable +internal fun UnableToLoadData(onRetryClick: () -> Unit, modifier: Modifier = Modifier) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(R.string.markets_loading_error_title), + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.text.tertiary, + ) + SecondarySmallButton( + config = SmallButtonConfig( + text = resourceReference(R.string.try_to_load_data_again_button_title), + onClick = onRetryClick, + ), + ) + } +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview() { + TangemThemePreview { + UnableToLoadData(onRetryClick = {}) + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/entity/ListItem.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/entity/ListItem.kt deleted file mode 100644 index 70f3921dac..0000000000 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/entity/ListItem.kt +++ /dev/null @@ -1,3 +0,0 @@ -package com.tangem.features.markets.ui.entity - -sealed class ListItem \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/entity/MarketsListItemUM.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/entity/MarketsListItemUM.kt index a6f2c0c038..a561cf8517 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/entity/MarketsListItemUM.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/entity/MarketsListItemUM.kt @@ -17,6 +17,7 @@ data class MarketsListItemUM( val trendPercentText: String, val trendType: PriceChangeType, val chardData: MarketChartRawData?, + val showUnder100kMarketCap: Boolean = false, ) { val chartType: MarketChartLook.Type = when (trendType) { PriceChangeType.UP, diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/entity/MarketsListUM.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/entity/MarketsListUM.kt index 602191058c..9167060698 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/entity/MarketsListUM.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/entity/MarketsListUM.kt @@ -3,6 +3,7 @@ package com.tangem.features.markets.ui.entity import androidx.compose.runtime.Immutable import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.fields.entity.SearchBarUM +import com.tangem.core.ui.event.StateEvent import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.features.markets.impl.R @@ -17,6 +18,9 @@ internal data class MarketsListUM( val onIntervalClick: (TrendInterval) -> Unit, val onSortByButtonClick: () -> Unit, ) { + val isInSearchMode + get() = searchBar.isActive + enum class TrendInterval(val text: TextReference) { H24(resourceReference(R.string.markets_selector_interval_24h_title)), D7(resourceReference(R.string.markets_selector_interval_7d_title)), @@ -37,10 +41,18 @@ sealed class ListUM { data class Content( val items: ImmutableList, + val showUnder100kTokens: Boolean, val loadMore: () -> Unit, val visibleIdsChanged: (List) -> Unit, + val onShowTokensUnder100kClicked: () -> Unit, + val triggerScrollReset: StateEvent, ) : ListUM() data object Loading : ListUM() + + data class LoadingError( + val onRetryClicked: () -> Unit, + ) : ListUM() + data object SearchNothingFound : ListUM() } \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/SortByBottomSheetContentUM.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/entity/SortByBottomSheetContentUM.kt similarity index 70% rename from features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/SortByBottomSheetContentUM.kt rename to features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/entity/SortByBottomSheetContentUM.kt index 092b37423a..af420055f5 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/SortByBottomSheetContentUM.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/entity/SortByBottomSheetContentUM.kt @@ -1,7 +1,6 @@ -package com.tangem.features.markets.model +package com.tangem.features.markets.ui.entity import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent -import com.tangem.features.markets.ui.entity.SortByTypeUM data class SortByBottomSheetContentUM( val selectedOption: SortByTypeUM,