Updated on 2026-08-14

This commit is contained in:
Tangem 2024-07-23 12:02:56 +03:00
parent 4ec02d0447
commit f426a1f922
26 changed files with 748 additions and 247 deletions

View file

@ -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<Token>,
@Json(name = "total")

View file

@ -68,4 +68,10 @@ sealed class BatchAction<out TKey, out TRequestParams, out TUpdate> {
class CancelUpdates<TKey, TUpdate>(
val predicate: (UpdateBatches<TKey, TUpdate>) -> Boolean,
) : BatchAction<TKey, Nothing, TUpdate>()
/**
* Clears the state and stops all current batch loading and updates
* After this status becomes [PaginationStatus.None]
*/
data object Reset : BatchAction<Nothing, Nothing, Nothing>()
}

View file

@ -12,10 +12,12 @@ sealed class BatchFetchResult<out TData> {
* 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<TData>(
val data: TData,
val empty: Boolean,
val last: Boolean,
) : BatchFetchResult<TData>()

View file

@ -92,11 +92,7 @@ private class DefaultBatchListSource<TKey, TData, TRequestParams : Any, TUpdate>
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<TKey, TData, TRequestParams : Any, TUpdate>
}
}
@Suppress("CyclomaticComplexMethod")
private fun collectActions(action: BatchAction<TKey, TRequestParams, TUpdate>) {
when (action) {
is BatchAction.Reload -> {
@ -165,6 +162,9 @@ private class DefaultBatchListSource<TKey, TData, TRequestParams : Any, TUpdate>
loadMoreActionJob?.cancel()
reloadActionJob?.cancel()
}
BatchAction.Reset -> {
resetState()
}
}
}
@ -237,13 +237,17 @@ private class DefaultBatchListSource<TKey, TData, TRequestParams : Any, TUpdate>
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<TKey, TData, TRequestParams : Any, TUpdate>
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<TKey, TData, TRequestParams : Any, TUpdate>
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 {

View file

@ -32,6 +32,7 @@ class LimitOffsetBatchFetcher<TRequestParams : Any, TData>(
suspend fun fetch(
request: Request<TRequestParams>,
lastResult: BatchFetchResult<TData>?,
isFirstBatchFetching: Boolean,
): BatchFetchResult<TData>
}
@ -45,7 +46,7 @@ class LimitOffsetBatchFetcher<TRequestParams : Any, TData>(
)
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<TRequestParams : Any, TData>(
}
val res = runCatching {
subFetcher.fetch(req, lastResult)
subFetcher.fetch(request = req, lastResult = lastResult, isFirstBatchFetching = false)
}.getOrElse {
currentCoroutineContext().ensureActive()
BatchFetchResult.Error(it)

View file

@ -68,6 +68,7 @@
<string name="cardano_max_amount_has_token_title">Недостаточно ADA</string>
<string name="common_accept">Принять</string>
<string name="common_access_denied">Доступ запрещен</string>
<string name="common_allow">Разрешить</string>
<string name="common_apply">Применить</string>
<string name="common_approval">Одобрение</string>
<string name="common_approve">Подтвердить</string>

View file

@ -337,7 +337,12 @@
<string name="markets_common_my_portfolio">My portfolio</string>
<string name="markets_common_title">Market</string>
<string name="markets_generate_addresses_notification">To generate addresses for selected networks, you need to attach a Tangem card</string>
<string name="markets_loading_error_title">Unable to load the data…</string>
<string name="markets_quick_actions">Quick actions</string>
<string name="markets_search_result_title">Result</string>
<string name="markets_search_see_tokens_under_100k">See tokens under 100k market cap</string>
<string name="markets_search_show_tokens">Show tokens</string>
<string name="markets_search_token_no_result_title">No result</string>
<string name="markets_select_network">Select network</string>
<string name="markets_select_wallet">Select wallet</string>
<string name="markets_selector_interval_1m_title">1m</string>
@ -722,6 +727,7 @@
<string name="transaction_history_operation">Operation</string>
<string name="transaction_history_transaction_from_address">from: %s</string>
<string name="transaction_history_transaction_to_address">to: %s</string>
<string name="try_to_load_data_again_button_title">Try again</string>
<string name="twin_error_same_card">You\'ve scanned the same card. To create a twin wallet you need to scan the card with number %d</string>
<string name="twin_error_wrong_twin">You\'ve scanned wrong twin card. Please try another one</string>
<string name="twins_onboarding_description_format">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.</string>

View file

@ -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<TokenMarketListConfig, List<TokenMarket>> {
private fun createTokenMarketsFetcher(firstBatchSize: Int, nextBatchSize: Int) = LimitOffsetBatchFetcher(
prefetchDistance = firstBatchSize,
batchSize = nextBatchSize,
subFetcher = object : LimitOffsetBatchFetcher.SubFetcher<TokenMarketListConfig, List<TokenMarket>> {
var requestTimeStamp: Long? = null // TODO when backend is ready
val requestTimeStamp = AtomicLong(0)
override suspend fun fetch(
request: LimitOffsetBatchFetcher.Request<TokenMarketListConfig>,
lastResult: BatchFetchResult<List<TokenMarket>>?,
): BatchFetchResult<List<TokenMarket>> {
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<TokenMarketListConfig>,
lastResult: BatchFetchResult<List<TokenMarket>>?,
isFirstBatchFetching: Boolean,
): BatchFetchResult<List<TokenMarket>> {
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<Int, TokenMarketListConfig, TokenMarketUpdateRequest>,
firstBatchSize: Int,
nextBatchSize: Int,
): BatchFlow<Int, List<TokenMarket>, 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()
}

View file

@ -9,6 +9,14 @@ import com.tangem.utils.converter.Converter
class TokenMarketListConverter : Converter<TokenMarketListResponse, List<TokenMarket>> {
override fun convert(value: TokenMarketListResponse): List<TokenMarket> {
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<TokenMarketListResponse, List<TokenMa
symbol = token.symbol,
marketRating = token.marketRating,
marketCap = token.marketCap,
imageHost = value.imageHost,
imageHost = imageHost,
tokenQuotes = TokenQuotes(
currentPrice = token.currentPrice,
priceChanges = mapOf(

View file

@ -10,7 +10,19 @@ typealias TokenListBatchFlow = BatchFlow<Int, List<TokenMarket>, 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),
}
}

View file

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

View file

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

View file

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

View file

@ -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<List<String>>(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)
}

View file

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

View file

@ -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<TrendInterval>,
private val currentAppCurrency: Provider<AppCurrency>,
private val currentSearchText: Provider<String?>,
private val currentSortByType: Provider<SortByTypeUM>,
private val modelScope: CoroutineScope,
private val dispatchers: CoroutineDispatcherProvider,
) {
private val actionsFlow = MutableSharedFlow<BatchAction<Int, TokenMarketListConfig, TokenMarketUpdateRequest>>()
private val batchFlow = getMarketsTokenListFlowUseCase(
TokenListBatchingContext(
batchingContext = TokenListBatchingContext(
actionsFlow = actionsFlow,
coroutineScope = modelScope,
),
batchFlowType = batchFlowType,
)
val uiItems: StateFlow<ImmutableList<MarketsListItemUM>>
@ -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<List<Batch<Int, List<MarketsListItemUM>>>>(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<String>): Set<Int> {
val currentData = batchFlow.state.value.data

View file

@ -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<String>) -> 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<MarketsListItemUM>) {
fun onUiItemsChanged(
isInErrorState: Boolean,
isSearchNotFound: Boolean,
uiItems: ImmutableList<MarketsListItemUM>,
) {
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
},
)
}
}
}

View file

@ -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<List<TokenMarket>>) {
Timber.tag(tag).d(
"""
Status
$status
""".trimIndent(),
)
}
internal fun logAction(tag: String, action: BatchAction<Int, TokenMarketListConfig, TokenMarketUpdateRequest>) {
when (action) {
is BatchAction.Reload -> Timber.tag(tag).d(

View file

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

View file

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

View file

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

View file

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

View file

@ -1,3 +0,0 @@
package com.tangem.features.markets.ui.entity
sealed class ListItem

View file

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

View file

@ -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<MarketsListItemUM>,
val showUnder100kTokens: Boolean,
val loadMore: () -> Unit,
val visibleIdsChanged: (List<String>) -> Unit,
val onShowTokensUnder100kClicked: () -> Unit,
val triggerScrollReset: StateEvent<Unit>,
) : ListUM()
data object Loading : ListUM()
data class LoadingError(
val onRetryClicked: () -> Unit,
) : ListUM()
data object SearchNothingFound : ListUM()
}

View file

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