Updated on 2026-08-14

This commit is contained in:
Tangem 2024-07-17 17:43:48 +03:00
parent 3c8fb7f668
commit 21ef61585b
24 changed files with 1255 additions and 85 deletions

View file

@ -74,6 +74,7 @@ dependencies {
implementation(projects.domain.qrScanning.models)
implementation(projects.domain.staking)
implementation(projects.domain.walletConnect)
implementation(projects.domain.markets)
implementation(projects.common)
implementation(projects.common.routing)
@ -109,6 +110,7 @@ dependencies {
implementation(projects.data.qrScanning)
implementation(projects.data.staking)
implementation(projects.data.walletConnect)
implementation(projects.data.markets)
/** Features */
implementation(projects.features.onboarding)

View file

@ -8,6 +8,15 @@ import com.tangem.wallet.R
import timber.log.Timber
fun FragmentManager.showFragmentAllowingStateLoss(name: String, fragmentProvider: Provider<Fragment>) {
if (backStackEntryCount > 0) {
val currentFragmentName = getBackStackEntryAt(backStackEntryCount - 1).name
if (name == currentFragmentName) {
Timber.d("Fragment $name is already at the top of the stack")
return
}
}
Timber.d("Showing $name route")
val isPoppedBack = popBackStackImmediate(name, 0)

View file

@ -0,0 +1,22 @@
package com.tangem.tap.di.domain
import com.tangem.domain.markets.GetMarketsTokenListFlowUseCase
import com.tangem.domain.markets.repositories.MarketsTokenRepository
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object MarketsDomainModule {
@Provides
@Singleton
fun provideGetMarketsTokenListFlowUseCase(
marketsTokenRepository: MarketsTokenRepository,
): GetMarketsTokenListFlowUseCase {
return GetMarketsTokenListFlowUseCase(marketsTokenRepository = marketsTokenRepository)
}
}

View file

@ -124,7 +124,9 @@ class NetworkModule {
context = context,
appVersionProvider = appVersionProvider,
baseUrl = PROD_V1_TANGEM_TECH_BASE_URL,
timeoutSeconds = TANGEM_TECH_SERVICE_TIMEOUT_SECONDS,
timeouts = Timeouts(
callTimeoutSeconds = TANGEM_TECH_SERVICE_TIMEOUT_SECONDS,
),
requestHeaders = listOf(AppVersionPlatformHeaders(appVersionProvider)),
)
}
@ -142,6 +144,11 @@ class NetworkModule {
context = context,
appVersionProvider = appVersionProvider,
baseUrl = DEV_V1_TANGEM_TECH_BASE_URL,
timeouts = Timeouts(
callTimeoutSeconds = TANGEM_TECH_MARKETS_SERVICE_TIMEOUT_SECONDS,
connectTimeoutSeconds = TANGEM_TECH_MARKETS_SERVICE_TIMEOUT_SECONDS,
readTimeoutSeconds = TANGEM_TECH_MARKETS_SERVICE_TIMEOUT_SECONDS,
),
requestHeaders = listOf(AppVersionPlatformHeaders(appVersionProvider)),
)
}
@ -151,16 +158,25 @@ class NetworkModule {
context: Context,
appVersionProvider: AppVersionProvider,
baseUrl: String,
timeoutSeconds: Long? = null,
timeouts: Timeouts = Timeouts(),
requestHeaders: List<RequestHeader> = listOf(CacheControlHeader, AppVersionPlatformHeaders(appVersionProvider)),
): T {
val client = OkHttpClient.Builder()
.let { builder ->
if (timeoutSeconds != null) {
builder.callTimeout(timeoutSeconds, TimeUnit.SECONDS)
} else {
builder
var b = builder
if (timeouts.callTimeoutSeconds != null) {
b = b.callTimeout(timeouts.callTimeoutSeconds, TimeUnit.SECONDS)
}
if (timeouts.connectTimeoutSeconds != null) {
b = b.connectTimeout(timeouts.connectTimeoutSeconds, TimeUnit.SECONDS)
}
if (timeouts.readTimeoutSeconds != null) {
b = b.readTimeout(timeouts.readTimeoutSeconds, TimeUnit.SECONDS)
}
if (timeouts.writeTimeoutSeconds != null) {
b = b.writeTimeout(timeouts.writeTimeoutSeconds, TimeUnit.SECONDS)
}
b
}
.addHeaders(
*requestHeaders.toTypedArray(),
@ -179,6 +195,13 @@ class NetworkModule {
.create(T::class.java)
}
private data class Timeouts(
val callTimeoutSeconds: Long? = null,
val connectTimeoutSeconds: Long? = null,
val readTimeoutSeconds: Long? = null,
val writeTimeoutSeconds: Long? = null,
)
private companion object {
const val STAKEKIT_BASE_URL = "https://api.stakek.it/v1/"
const val PROD_EXPRESS_BASE_URL = "https://express.tangem.com/v1/"
@ -191,5 +214,6 @@ class NetworkModule {
const val PROD_V2_TANGEM_TECH_BASE_URL = "https://api.tangem-tech.com/v2/"
const val TANGEM_TECH_SERVICE_TIMEOUT_SECONDS = 5L
const val TANGEM_TECH_MARKETS_SERVICE_TIMEOUT_SECONDS = 60L
}
}

View file

@ -1,5 +1,6 @@
package com.tangem.pagination
import com.tangem.pagination.exception.OperationWIthTheSameIdInProgress
import com.tangem.pagination.fetcher.BatchFetcher
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.BufferOverflow
@ -59,6 +60,7 @@ fun <TKey, TData, TRequestParams : Any, TUpdate> BatchListSource(
): BatchListSource<TKey, TData, TUpdate> =
DefaultBatchListSource(fetchDispatcher, context, generateNewKey, batchFetcher, updateFetcher)
@Suppress("LargeClass")
private class DefaultBatchListSource<TKey, TData, TRequestParams : Any, TUpdate>(
private val fetchDispatcher: CoroutineDispatcher,
private val context: BatchingContext<TKey, TRequestParams, TUpdate>,
@ -90,19 +92,21 @@ private class DefaultBatchListSource<TKey, TData, TRequestParams : Any, TUpdate>
awaitCancellation()
} finally {
withContext(NonCancellable) {
stopAllUpdates()
loadMoreActionJob = null
loadMoreActionJob = null
lastRequestResult.value = null
stopAllUpdates()
state.value = BatchListState(emptyList(), PaginationStatus.None)
}
}
}
scope.launch {
context.actionsFlow.collect { action ->
collectActions(action)
}
context.actionsFlow
.conflate()
.collect { action ->
collectActions(action)
}
}
}
@ -113,7 +117,7 @@ private class DefaultBatchListSource<TKey, TData, TRequestParams : Any, TUpdate>
loadMoreActionJob?.cancel()
reloadActionJob?.cancel()
stopAllUpdates()
reloadActionJob = scope.launch(fetchDispatcher) {
reloadActionJob = scope.launchFetch {
reloadTask(action)
}
}
@ -122,15 +126,25 @@ private class DefaultBatchListSource<TKey, TData, TRequestParams : Any, TUpdate>
return
}
loadMoreActionJob = scope.launch(fetchDispatcher) {
loadMoreActionJob = scope.launchFetch {
reloadActionJob?.join()
loadMoreTask(action)
}
}
is BatchAction.UpdateBatches -> {
if (reloadActionJob?.isActive == true) {
return
}
if (updateFetcher == null) return
// If the request with the same operationId is in progress, skip the request
if (updateInProgressExists(action.operationId)) return
if (updateInProgressExists(action.operationId)) {
updateResults.tryEmit(
action.updateRequest to BatchUpdateResult.Error(
OperationWIthTheSameIdInProgress(action.operationId),
),
)
}
if (action.async) {
collectAsyncUpdateAction(action)
@ -140,6 +154,7 @@ private class DefaultBatchListSource<TKey, TData, TRequestParams : Any, TUpdate>
}
BatchAction.CancelAllUpdates -> {
if (updateFetcher == null) return
stopAllUpdates()
}
is BatchAction.CancelUpdates -> {
@ -154,9 +169,10 @@ private class DefaultBatchListSource<TKey, TData, TRequestParams : Any, TUpdate>
}
private fun collectAsyncUpdateAction(action: BatchAction.UpdateBatches<TKey, TUpdate>) {
val job = scope.launch(fetchDispatcher) {
val job = scope.launchFetch {
updateBatchesAsyncTask(action)
}
val actionJob = action to job
updateAsyncJobs.update { it + actionJob }
@ -172,7 +188,7 @@ private class DefaultBatchListSource<TKey, TData, TRequestParams : Any, TUpdate>
private fun collectSyncUpdateAction(action: BatchAction.UpdateBatches<TKey, TUpdate>) {
// Lazily start a job so we can avoid batch update collisions
// by waiting for other tasks with the same keys to complete
val job = scope.launch(fetchDispatcher, start = CoroutineStart.LAZY) {
val job = scope.launchFetch(start = CoroutineStart.LAZY) {
updateBatchesTask(action)
}
@ -180,7 +196,7 @@ private class DefaultBatchListSource<TKey, TData, TRequestParams : Any, TUpdate>
waitingUpdateJobs.update { it + actionJob }
scope.launch(fetchDispatcher) {
scope.launchFetch {
// Wait for other update tasks that mutate batches with the same keys
updateJobs.first { workingJobs ->
action.keys.intersect(workingJobs.map { it.first.keys }.flatten().toSet()).isEmpty()
@ -217,6 +233,8 @@ private class DefaultBatchListSource<TKey, TData, TRequestParams : Any, TUpdate>
BatchFetchResult.Error(it)
}
currentCoroutineContext().ensureActive()
state.value = when (res) {
is BatchFetchResult.Success -> {
val key = generateNewKey(listOf())
@ -306,10 +324,11 @@ private class DefaultBatchListSource<TKey, TData, TRequestParams : Any, TUpdate>
updateRequest = action.updateRequest,
)
} catch (t: Throwable) {
currentCoroutineContext().ensureActive()
BatchUpdateResult.Error(t)
}
currentCoroutineContext().ensureActive()
if (result is BatchUpdateResult.Success) {
state.update { currentState ->
val resMap = result.data.associateBy { it.key }
@ -333,7 +352,13 @@ private class DefaultBatchListSource<TKey, TData, TRequestParams : Any, TUpdate>
val updateContext = UpdateContext(request = action.updateRequest, action.keys)
with(updateFetcher) {
updateContext.fetchUpdateAsync(batchesToUpdate, action.updateRequest)
runCatching {
updateContext.fetchUpdateAsync(batchesToUpdate, action.updateRequest).also {
currentCoroutineContext().ensureActive()
}
}.getOrElse {
updateResults.emit(action.updateRequest to BatchUpdateResult.Error(it))
}
}
}
@ -342,6 +367,8 @@ private class DefaultBatchListSource<TKey, TData, TRequestParams : Any, TUpdate>
object : BatchUpdateFetcher.UpdateContext<TKey, TData> {
override suspend fun update(update: List<Batch<TKey, TData>>.() -> BatchUpdateResult<TKey, TData>) {
currentCoroutineContext().ensureActive()
val stateToFetchUpdateBasedOn = state.value.data.filter {
keysToUpdate.contains(it.key)
}
@ -349,7 +376,6 @@ private class DefaultBatchListSource<TKey, TData, TRequestParams : Any, TUpdate>
val result = runCatching {
stateToFetchUpdateBasedOn.update()
}.getOrElse {
currentCoroutineContext().ensureActive()
BatchUpdateResult.Error(it)
}
@ -430,4 +456,11 @@ private class DefaultBatchListSource<TKey, TData, TRequestParams : Any, TUpdate>
}
}
}
private fun CoroutineScope.launchFetch(
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit,
): Job {
return launch(context = fetchDispatcher + SupervisorJob(), start = start, block = block)
}
}

View file

@ -0,0 +1,5 @@
package com.tangem.pagination.exception
class OperationWIthTheSameIdInProgress(operationId: String) : RuntimeException(
"Operation is already in progress - id:$operationId ",
)

View file

@ -0,0 +1,74 @@
package com.tangem.core.ui.components.inputrow
import android.content.res.Configuration
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
@Composable
fun InputRowChecked(text: TextReference, checked: Boolean, modifier: Modifier = Modifier) {
Row(
modifier = modifier.padding(TangemTheme.dimens.spacing12),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
modifier = Modifier.weight(1f),
text = text.resolveReference(),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.primary1,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
AnimatedVisibility(
modifier = Modifier,
visible = checked,
) {
Icon(
painter = rememberVectorPainter(image = ImageVector.vectorResource(id = R.drawable.ic_check_24)),
tint = TangemTheme.colors.icon.accent,
contentDescription = null,
)
}
if (checked.not()) {
Box(Modifier.height(TangemTheme.dimens.size24))
}
}
}
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview() {
TangemThemePreview {
Column(Modifier.background(TangemTheme.colors.background.primary)) {
InputRowChecked(
text = stringReference("Title Title Title Title Title Title Title Title Title"),
checked = false,
modifier = Modifier.width(300.dp),
)
InputRowChecked(
text = stringReference("Title Title Title Title Title Title Title Title Title"),
checked = true,
modifier = Modifier.width(300.dp),
)
}
}
}

View file

@ -3,7 +3,7 @@ package com.tangem.core.ui.components.inputrow.inner
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Divider
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -19,7 +19,7 @@ fun DividerContainer(
Box(modifier = modifier) {
content()
if (showDivider) {
Divider(
HorizontalDivider(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(paddingValues),

View file

@ -1,5 +1,7 @@
package com.tangem.core.ui.utils
import android.icu.text.CompactDecimalFormat
import android.os.Build
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.utils.StringsSigns.DASH_SIGN
import com.tangem.utils.StringsSigns.LOWER_SIGN
@ -219,5 +221,77 @@ object BigDecimalFormatter {
}
}
/**
* Adds a proper currency sign for the provided formatted [amount]
* ex. '10.0k" -> "$10.0k", "string" -> "$string"
*/
fun addCurrencySymbolToStringAmount(
amount: String,
fiatCurrencyCode: String,
fiatCurrencySymbol: String,
locale: Locale = Locale.getDefault(),
): String {
val sampleAmount = BigDecimal.TEN
val currency = getCurrency(fiatCurrencyCode)
val formatter = NumberFormat.getCurrencyInstance(locale).apply {
maximumFractionDigits = 0
minimumFractionDigits = 0
this.currency = currency
}
val formatted = formatter.format(sampleAmount)
.replace(currency.getSymbol(locale), fiatCurrencySymbol)
.replace(sampleAmount.toString(), amount)
return formatted
}
/**
* "123456.6" -> "$123.457K"
* "12345.6" -> "$123.046K"
*/
@Suppress("MagicNumber")
fun formatCompactAmount(
amount: BigDecimal,
fiatCurrencyCode: String,
fiatCurrencySymbol: String,
locale: Locale = Locale.getDefault(),
): String {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
return BigDecimalFormatterCompat.formatCompactAmountNoLocaleContext(
amount = amount,
fiatCurrencyCode = fiatCurrencyCode,
fiatCurrencySymbol = fiatCurrencySymbol,
locale = locale,
)
}
val scaledAmount = amount.setScale(0, RoundingMode.HALF_UP)
val digitsCount = scaledAmount.longValueExact().toString().count()
val digitsToFormat = 6 - when (digitsCount % 3) {
0 -> 0
1 -> 2
else -> 1
}
val formatter = CompactDecimalFormat.getInstance(
locale,
CompactDecimalFormat.CompactStyle.SHORT,
).apply {
minimumSignificantDigits = 4
maximumSignificantDigits = digitsToFormat
}
val rawAmount = formatter.format(amount.setScale(0, RoundingMode.HALF_UP))
return addCurrencySymbolToStringAmount(
amount = rawAmount,
fiatCurrencyCode = fiatCurrencyCode,
fiatCurrencySymbol = fiatCurrencySymbol,
locale = locale,
)
}
private fun BigDecimal.isLessThanThreshold() = this > BigDecimal.ZERO && this < FIAT_FORMAT_THRESHOLD
}

View file

@ -0,0 +1,52 @@
package com.tangem.core.ui.utils
import java.math.BigDecimal
import java.math.RoundingMode
import java.util.Locale
internal object BigDecimalFormatterCompat {
/**
* Formats value as [BigDecimalFormatter.formatCompactAmount] does using only "T","B","M","K" suffixes
* Used for < API24 compatibility
*/
@Suppress("MagicNumber", "UnnecessaryParentheses")
fun formatCompactAmountNoLocaleContext(
amount: BigDecimal,
fiatCurrencyCode: String,
fiatCurrencySymbol: String,
locale: Locale = Locale.getDefault(),
): String {
val value = amount.setScale(0, RoundingMode.HALF_UP).longValueExact()
val formatted = when {
value > 1_000_000_000_000L -> {
val trillion = value / 1_000_000_000_000
val billion = (value % 1_000_000_000_000) / 1_000_000_000
"$trillion.${billion}T"
}
value > 1_000_000_000L -> {
val billion = value / 1_000_000_000
val million = (value % 1_000_000_000) / 1_000_000
"$billion.${million}B"
}
value > 1_000_000L -> {
val million = value / 1_000_000
val thousand = (value % 1_000_000) / 1_000
"$million.${thousand}M"
}
value > 1_000L -> {
val thousand = value / 1_000
"${thousand}K"
}
else -> return value.toString()
}
return BigDecimalFormatter.addCurrencySymbolToStringAmount(
amount = formatted,
fiatCurrencyCode = fiatCurrencyCode,
fiatCurrencySymbol = fiatCurrencySymbol,
locale = locale,
)
}
}

View file

@ -63,11 +63,13 @@ internal class MarketsBatchUpdateFetcher(
}
}
is TokenMarketUpdateRequest.UpdateQuotes -> {
val quotesRes = tangemTechApi.getQuotes(
currencyId = updateRequest.currencyId,
coinIds = idsToUpdate.joinToString(separator = ","),
fields = quoteFields.joinToString(separator = ","),
).getOrThrow()
val quotesRes = retryOnError {
tangemTechApi.getQuotes(
currencyId = updateRequest.currencyId,
coinIds = idsToUpdate.map { it.second }.flatten().joinToString(separator = ","),
fields = quoteFields.joinToString(separator = ","),
).getOrThrow()
}
update {
val res = toUpdate.map { batch ->

View file

@ -15,6 +15,11 @@ dependencies {
/* Project - API */
api(projects.features.markets.api)
/* Domain */
implementation(projects.domain.markets)
implementation(projects.domain.appCurrency)
implementation(projects.domain.appCurrency.models)
/* Compose */
implementation(deps.compose.coil)
implementation(deps.compose.foundation)

View file

@ -1,6 +1,7 @@
package com.tangem.features.markets.component.impl
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
@ -30,6 +31,10 @@ internal class DefaultMarketsListComponent @AssistedInject constructor(
) {
val state by model.state.collectAsStateWithLifecycle()
LaunchedEffect(bottomSheetState.value) {
model.containerBottomSheetState.value = bottomSheetState.value
}
MarketsList(
onHeaderSizeChange = onHeaderSizeChange,
state = state,

View file

@ -1,61 +1,174 @@
package com.tangem.features.markets.model
import androidx.compose.runtime.Stable
import arrow.core.getOrElse
import com.tangem.core.decompose.di.ComponentScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.features.markets.impl.R
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
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.ui.entity.ListUM
import com.tangem.features.markets.ui.entity.MarketsListUM
import com.tangem.features.markets.ui.entity.SortByTypeUM
import com.tangem.features.markets.ui.preview.MarketChartListItemPreviewDataProvider
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.toImmutableList
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
private const val UPDATE_QUOTES_TIMER_MILLIS = 60000L
@ComponentScoped
@Stable
internal class MarketsListModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
getMarketsTokenListFlowUseCase: GetMarketsTokenListFlowUseCase,
getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
) : Model() {
val state = MutableStateFlow(
MarketsListUM(
list = ListUM.Loading,
searchBar = SearchBarUM(
placeholderText = resourceReference(R.string.manage_tokens_search_placeholder),
query = "",
onQueryChange = {},
isActive = false,
onActiveChange = { },
),
selectedSortBy = SortByTypeUM.Rating,
selectedInterval = MarketsListUM.TrendInterval.H24,
onIntervalClick = {},
onSortByButtonClick = {},
),
private val currentAppCurrency = getSelectedAppCurrencyUseCase()
.map { maybeAppCurrency ->
maybeAppCurrency.getOrElse { AppCurrency.Default }
}.stateIn(
scope = modelScope,
started = SharingStarted.Eagerly,
initialValue = AppCurrency.Default,
)
private val visibleItemIds = MutableStateFlow<List<String>>(emptyList())
val containerBottomSheetState = MutableStateFlow(BottomSheetState.COLLAPSED)
private val marketsListUMStateManager = MarketsListUMStateManager(
onLoadMoreUiItems = { activeListManager.loadMore() },
visibleItemsChanged = { visibleItemIds.value = it },
)
private val marketsListManager = MarketsListUiItemsManager(
logTag = "main",
getMarketsTokenListFlowUseCase = getMarketsTokenListFlowUseCase,
currentAppCurrency = Provider { currentAppCurrency.value },
currentTrendInterval = Provider { marketsListUMStateManager.selectedInterval },
modelScope = modelScope,
dispatchers = dispatchers,
)
private val searchMarketsListManager = MarketsListUiItemsManager(
logTag = "search",
getMarketsTokenListFlowUseCase = getMarketsTokenListFlowUseCase,
currentAppCurrency = Provider { currentAppCurrency.value },
currentTrendInterval = Provider { marketsListUMStateManager.selectedInterval },
modelScope = modelScope,
dispatchers = dispatchers,
)
private var activeListManager: MarketsListUiItemsManager = marketsListManager
val state = marketsListUMStateManager.state.asStateFlow()
init {
modelScope.launch {
delay(timeMillis = 5000)
state.update {
it.copy(
list = ListUM.Content(
items = MarketChartListItemPreviewDataProvider().values
.flatMap { item -> List(size = 10) { item } }
.mapIndexed { index, item ->
item.copy(id = index.toString())
}
.toImmutableList(),
),
)
}
marketsListManager.uiItems
.collectLatest {
marketsListUMStateManager.onUiItemsChanged(it)
}
}
state.onEach {
if (it.list !is ListUM.Content) {
visibleItemIds.value = emptyList()
}
}.launchIn(modelScope)
// update all lists when user's currency has changed
currentAppCurrency
.drop(1)
.onEach {
marketsListManager.reload(
interval = marketsListUMStateManager.selectedInterval,
sortBy = marketsListUMStateManager.selectedSortByType,
)
if (marketsListUMStateManager.isInSearchState) {
// TODO
searchMarketsListManager.reload(
interval = marketsListUMStateManager.selectedInterval,
sortBy = marketsListUMStateManager.selectedSortByType,
)
}
}.launchIn(modelScope)
// load charts when new batch is being loaded
marketsListManager.onLastBatchLoadedSuccess
.onEach {
marketsListManager.loadCharts(setOf(it), marketsListUMStateManager.selectedInterval)
modelScope.loadQuotesWithTimer(timeMillis = UPDATE_QUOTES_TIMER_MILLIS)
}
.launchIn(modelScope)
// listen currently selected interval, update charts if sorting=rating, or reload all list
modelScope.launch(dispatchers.default) {
marketsListUMStateManager.state
.map { it.selectedInterval }
.distinctUntilChanged()
.drop(1)
.collectLatest { interval ->
when (marketsListUMStateManager.selectedSortByType) {
SortByTypeUM.Rating -> {
marketsListManager.updateUIWithSameState()
val batchKeys = marketsListManager.getBatchKeysByItemIds(visibleItemIds.value)
marketsListManager.loadCharts(batchKeys, interval)
}
else -> marketsListManager.reload(interval, marketsListUMStateManager.selectedSortByType)
}
}
}
// reload list when sorting type has changed
modelScope.launch {
marketsListUMStateManager.state
.map { it.selectedSortBy }
.distinctUntilChanged()
.drop(1)
.collectLatest {
marketsListManager.reload(marketsListUMStateManager.selectedInterval, it)
}
}
// listen current visible batch and update charts
modelScope.launch(dispatchers.default) {
visibleItemIds
.mapNotNull {
if (it.isNotEmpty()) {
activeListManager.getBatchKeysByItemIds(visibleItemIds.value)
} else {
null
}
}
.distinctUntilChanged()
.collectLatest { visibleBatchKeys ->
activeListManager.loadCharts(visibleBatchKeys, marketsListUMStateManager.selectedInterval)
}
}
// initial loading
marketsListManager.reload(
interval = marketsListUMStateManager.selectedInterval,
sortBy = marketsListUMStateManager.selectedSortByType,
)
}
// TODO
private var updateQuotesJob = JobHolder()
private fun CoroutineScope.loadQuotesWithTimer(timeMillis: Long) {
launch {
while (true) {
delay(timeMillis)
// Update quotes only when the container bottom sheet is in the expanded state
containerBottomSheetState.first { it == BottomSheetState.EXPANDED }
activeListManager.updateQuotes()
}
}.saveIn(updateQuotesJob)
}
}

View file

@ -0,0 +1,9 @@
package com.tangem.features.markets.model
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.features.markets.ui.entity.SortByTypeUM
data class SortByBottomSheetContentUM(
val selectedOption: SortByTypeUM,
val onOptionClicked: (SortByTypeUM) -> Unit,
) : TangemBottomSheetConfigContent

View file

@ -0,0 +1,144 @@
package com.tangem.features.markets.model.converters
import com.tangem.common.ui.charts.state.DefaultPointValuesConverter
import com.tangem.common.ui.charts.state.MarketChartData
import com.tangem.common.ui.charts.state.MarketChartRawData
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.TokenMarket
import com.tangem.features.markets.ui.entity.MarketsListItemUM
import com.tangem.features.markets.ui.entity.MarketsListUM.TrendInterval
import com.tangem.utils.converter.Converter
import java.math.BigDecimal
import java.math.RoundingMode
internal class MarketsTokenItemConverter(
private val currentTrendInterval: TrendInterval,
private val appCurrency: AppCurrency,
) : Converter<TokenMarket, MarketsListItemUM> {
override fun convert(value: TokenMarket): MarketsListItemUM {
return MarketsListItemUM(
id = value.id,
name = value.name,
currencySymbol = value.symbol,
ratingPosition = value.marketRating?.toString(),
marketCap = value.getMarketCap(),
iconUrl = value.imageUrlLarge,
price = value.getCurrentPrice(),
trendPercentText = value.getTrendPercent(),
trendType = value.getTrendType(),
chardData = value.getChartData(),
)
}
fun update(prev: TokenMarket, prevUI: MarketsListItemUM, new: TokenMarket): MarketsListItemUM {
require(prev.id == new.id) {
"Ids is not the same during update TokenMarket item: previousItem[${prev.id}] != newItem[${new.id}]"
}
return prevUI.copy(
name = new.name,
currencySymbol = new.symbol,
ratingPosition = new.marketRating?.toString(),
marketCap = ifChanged(prev.marketCap, new.marketCap, prevUI.marketCap) { new.getMarketCap() },
iconUrl = new.imageUrlLarge,
price = ifChanged(prev = prev.tokenQuotes, new = new.tokenQuotes, prevR = prevUI.price) {
new.getCurrentPrice(
prev = prev,
)
},
trendPercentText = ifChanged(
prev.tokenQuotes,
new.tokenQuotes,
prevUI.trendPercentText,
) { new.getTrendPercent() },
trendType = ifChanged(prev.tokenQuotes, new.tokenQuotes, prevUI.trendType) { new.getTrendType() },
chardData = ifChanged(prev.tokenCharts, new.tokenCharts, prevUI.chardData) { new.getChartData() },
)
}
private inline fun <T, R> ifChanged(prev: T, new: T, prevR: R, force: Boolean = false, change: (T) -> R): R {
return if (force || prev != new) change(new) else prevR
}
private fun TokenMarket.getMarketCap(): String? {
val value = marketCap?.takeIf { marketCap != BigDecimal.ZERO } ?: return null
return BigDecimalFormatter.formatCompactAmount(
value,
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
)
}
private fun TokenMarket.getCurrentPrice(prev: TokenMarket? = null): MarketsListItemUM.Price {
val prevPrice = prev?.tokenQuotes?.currentPrice
val priceText = BigDecimalFormatter.formatFiatAmount(
fiatAmount = tokenQuotes.currentPrice,
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
)
val changeType = if (prevPrice != null) {
if (tokenQuotes.currentPrice > prevPrice) {
PriceChangeType.UP
} else {
PriceChangeType.DOWN
}
} else {
null
}
return MarketsListItemUM.Price(
text = priceText,
changeType = changeType,
)
}
private fun TokenMarket.getChartData(): MarketChartRawData? {
val chart = when (currentTrendInterval) {
TrendInterval.H24 -> tokenCharts.h24
TrendInterval.D7 -> tokenCharts.week
TrendInterval.M1 -> tokenCharts.month
}
return chart?.let { ct ->
DefaultPointValuesConverter.convert(
MarketChartData.Data(
y = ct.priceY,
x = ct.timeStamp.map { it.toBigDecimal() },
),
)
}
}
private fun TokenMarket.getTrendType(): PriceChangeType {
val percent = when (currentTrendInterval) {
TrendInterval.H24 -> tokenQuotes.h24Percent()
TrendInterval.D7 -> tokenQuotes.weekPercent()
TrendInterval.M1 -> tokenQuotes.monthPercent()
}.setScale(2, RoundingMode.UP)
return when (percent.compareTo(BigDecimal.ZERO)) {
1 -> PriceChangeType.UP
-1 -> PriceChangeType.DOWN
else -> PriceChangeType.NEUTRAL
}
}
private fun TokenMarket.getTrendPercent(): String {
val percent = when (currentTrendInterval) {
TrendInterval.H24 -> tokenQuotes.h24Percent()
TrendInterval.D7 -> tokenQuotes.weekPercent()
TrendInterval.M1 -> tokenQuotes.monthPercent()
}
return BigDecimalFormatter.formatPercent(
percent = percent,
useAbsoluteValue = true,
)
}
}

View file

@ -0,0 +1,106 @@
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.extensions.resourceReference
import com.tangem.features.markets.impl.R
import com.tangem.features.markets.model.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
@Stable
internal class MarketsListUMStateManager(
private val onLoadMoreUiItems: () -> Unit,
private val visibleItemsChanged: (itemsKeys: List<String>) -> 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 isInSearchState
get() = isInSearchStateFlow.value
set(value) { isInSearchStateFlow.value = value }
var selectedSortByType
get() = state.value.selectedSortBy
set(value) = state.update {
it.copy(
selectedSortBy = value,
sortByBottomSheet = it.sortByBottomSheet.copy(
content = (it.sortByBottomSheet.content as SortByBottomSheetContentUM).copy(
selectedOption = value,
),
),
)
}
var selectedInterval
get() = state.value.selectedInterval
set(value) = state.update { it.copy(selectedInterval = value) }
val state = MutableStateFlow(state())
fun onUiItemsChanged(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,
),
)
}
}
}
private fun state(): MarketsListUM = MarketsListUM(
list = ListUM.Loading,
searchBar = SearchBarUM(
placeholderText = resourceReference(R.string.manage_tokens_search_placeholder),
query = "", // TODO
onQueryChange = {}, // TODO
isActive = false, // TODO
onActiveChange = { }, // TODO
),
selectedSortBy = SortByTypeUM.Rating,
selectedInterval = MarketsListUM.TrendInterval.H24,
onIntervalClick = { selectedInterval = it },
onSortByButtonClick = { sortByBottomSheetIsShown = true },
sortByBottomSheet = TangemBottomSheetConfig(
isShow = false,
onDismissRequest = { sortByBottomSheetIsShown = false },
content = SortByBottomSheetContentUM(
selectedOption = SortByTypeUM.Rating,
onOptionClicked = ::onBottomSheetOptionClicked,
),
),
)
private fun onBottomSheetOptionClicked(sortByTypeUM: SortByTypeUM) {
state.update {
it.copy(
selectedSortBy = sortByTypeUM,
sortByBottomSheet = it.sortByBottomSheet.copy(
isShow = false,
content = (it.sortByBottomSheet.content as SortByBottomSheetContentUM).copy(
selectedOption = sortByTypeUM,
),
),
)
}
}
}

View file

@ -0,0 +1,275 @@
package com.tangem.features.markets.model.statemanager
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.logUpdateResults
import com.tangem.features.markets.ui.entity.MarketsListItemUM
import com.tangem.features.markets.ui.entity.MarketsListUM.TrendInterval
import com.tangem.features.markets.ui.entity.SortByTypeUM
import com.tangem.pagination.*
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
private const val LOG_EVENTS = false
internal class MarketsListUiItemsManager(
private val logTag: String = "main",
getMarketsTokenListFlowUseCase: GetMarketsTokenListFlowUseCase,
private val currentTrendInterval: Provider<TrendInterval>,
private val currentAppCurrency: Provider<AppCurrency>,
private val modelScope: CoroutineScope,
private val dispatchers: CoroutineDispatcherProvider,
) {
private val actionsFlow = MutableSharedFlow<BatchAction<Int, TokenMarketListConfig, TokenMarketUpdateRequest>>()
private val batchFlow = getMarketsTokenListFlowUseCase(
TokenListBatchingContext(
actionsFlow = actionsFlow,
coroutineScope = modelScope,
),
)
val uiItems: StateFlow<ImmutableList<MarketsListItemUM>>
get() = uiBatches
.map { batches ->
batches.asSequence()
.map { it.data }
.flatten()
.toImmutableList()
}
.distinctUntilChanged()
.stateIn(
scope = modelScope,
started = SharingStarted.Eagerly,
initialValue = persistentListOf(),
)
val onLastBatchLoadedSuccess = batchFlow.state
.distinctUntilChanged { old, new -> old.status === new.status }
.mapNotNull {
when (val status = it.status) {
is PaginationStatus.Paginating -> {
if (status.lastResult is BatchFetchResult.Success) {
it.data.lastOrNull()?.key
} else {
null
}
}
is PaginationStatus.EndOfPagination -> {
it.data.lastOrNull()?.key
}
else -> null
}
}
private val uiBatches = MutableStateFlow<List<Batch<Int, List<MarketsListItemUM>>>>(emptyList())
init {
batchFlow.state
.map { it.data }
.distinctUntilChanged { a, b ->
a.size == b.size && a.map { it.data }.flatten() == b.map { it.data }.flatten()
}
.onEachWithPrevious { prev, list ->
updateState(prev, list)
}
.flowOn(dispatchers.default)
.launchIn(modelScope)
if (LOG_EVENTS) {
batchFlow.updateResults
.onEach { logUpdateResults(logTag, it) }
.launchIn(modelScope)
actionsFlow
.onEach { logAction(logTag, it) }
.launchIn(modelScope)
}
}
private fun updateState(
previousList: List<Batch<Int, List<TokenMarket>>>?,
list: List<Batch<Int, List<TokenMarket>>>,
forceUpdate: Boolean = false,
) = uiBatches.update { items ->
val converter = MarketsTokenItemConverter(currentTrendInterval(), appCurrency = currentAppCurrency())
if (previousList == null || list.size < previousList.size || forceUpdate) {
list.map {
Batch(
key = it.key,
data = converter.convertList(it.data),
)
}
} else {
if (previousList.size != list.size) {
val keysToAdd = list.map { it.key }.subtract(previousList.map { it.key }.toSet())
val newBatches = list.filter { keysToAdd.contains(it.key) }
items + newBatches.map {
Batch(
key = it.key,
data = converter.convertList(it.data),
)
}
} else {
items.mapIndexed { batchIndex, batch ->
val prevBatch = previousList[batchIndex]
val newBatch = list[batchIndex]
if (previousList == newBatch) return@mapIndexed batch
Batch(
key = batch.key,
data = batch.data.mapIndexed { index, marketsListItemUM ->
val prevItem = prevBatch.data[index]
val newItem = newBatch.data[index]
converter.update(
prevItem,
marketsListItemUM,
newItem,
)
},
)
}
}
}
}
fun reload(interval: TrendInterval, sortBy: SortByTypeUM) {
modelScope.launch {
uiBatches.value = emptyList()
actionsFlow.emit(
BatchAction.Reload(
requestParams = TokenMarketListConfig(
fiatPriceCurrency = currentAppCurrency().code,
searchText = null,
showUnder100kMarketCapTokens = false,
priceChangeInterval = interval.toBatchRequestInterval(),
order = sortBy.toRequestOrder(),
),
),
)
}
}
fun loadMore() {
modelScope.launch {
actionsFlow.emit(BatchAction.LoadMore())
}
}
fun updateUIWithSameState() {
modelScope.launch(dispatchers.default) {
val current = batchFlow.state.value.data
updateState(current, current, forceUpdate = true)
}
}
fun loadCharts(batchKeys: Set<Int>, interval: TrendInterval) {
modelScope.launch(dispatchers.default) {
val currentData = batchFlow.state.value.data
val alreadyLoadedChartsBatchKeys = currentData
.filter {
val first = it.data.firstOrNull() ?: return@filter false
val chartByInterval = when (interval) {
TrendInterval.H24 -> first.tokenCharts.h24
TrendInterval.D7 -> first.tokenCharts.week
TrendInterval.M1 -> first.tokenCharts.month
}
chartByInterval != null
}
.map { it.key }
.toSet()
val batchesKeysToLoad = batchKeys.minus(alreadyLoadedChartsBatchKeys)
if (batchesKeysToLoad.isNotEmpty()) {
actionsFlow.emit(
BatchAction.UpdateBatches(
keys = batchesKeysToLoad,
updateRequest = TokenMarketUpdateRequest.UpdateChart(
interval = interval.toRequestInterval(),
currency = currentAppCurrency().code,
),
async = true,
operationId = batchesKeysToLoad.toString() + interval.toString(),
),
)
}
}
}
fun updateQuotes() {
modelScope.launch {
actionsFlow.emit(
BatchAction.CancelUpdates {
it.updateRequest is TokenMarketUpdateRequest.UpdateQuotes
},
)
actionsFlow.emit(
BatchAction.UpdateBatches(
keys = batchFlow.state.value.data.map { it.key }.toSet(),
updateRequest = TokenMarketUpdateRequest.UpdateQuotes(
currencyId = currentAppCurrency().code,
),
async = true,
operationId = "update quotes",
),
)
}
}
fun getBatchKeysByItemIds(ids: List<String>): Set<Int> {
val currentData = batchFlow.state.value.data
return currentData
.filter { d -> d.data.any { ids.contains(it.id) } }
.map { it.key }
.toSet()
}
private fun SortByTypeUM.toRequestOrder(): TokenMarketListConfig.Order {
return when (this) {
SortByTypeUM.Rating -> TokenMarketListConfig.Order.ByRating
SortByTypeUM.Trending -> TokenMarketListConfig.Order.Trending
SortByTypeUM.ExperiencedBuyers -> TokenMarketListConfig.Order.Buyers
SortByTypeUM.TopGainers -> TokenMarketListConfig.Order.TopGainers
SortByTypeUM.TopLosers -> TokenMarketListConfig.Order.TopLosers
}
}
private fun TrendInterval.toBatchRequestInterval(): TokenMarketListConfig.Interval {
return when (this) {
TrendInterval.H24 -> TokenMarketListConfig.Interval.H24
TrendInterval.D7 -> TokenMarketListConfig.Interval.WEEK
TrendInterval.M1 -> TokenMarketListConfig.Interval.MONTH
}
}
private fun TrendInterval.toRequestInterval(): PriceChangeInterval {
return when (this) {
TrendInterval.H24 -> PriceChangeInterval.H24
TrendInterval.D7 -> PriceChangeInterval.WEEK
TrendInterval.M1 -> PriceChangeInterval.MONTH
}
}
private fun <T> Flow<T>.onEachWithPrevious(operation: suspend (prev: T?, value: T) -> Unit): Flow<T> = flow {
var prev: T? = null
collect { value ->
operation(prev, value)
prev = value
emit(value)
}
}
}

View file

@ -0,0 +1,48 @@
package com.tangem.features.markets.model.utils
import com.tangem.domain.markets.TokenMarket
import com.tangem.domain.markets.TokenMarketListConfig
import com.tangem.domain.markets.TokenMarketUpdateRequest
import com.tangem.pagination.BatchAction
import com.tangem.pagination.BatchUpdateResult
import timber.log.Timber
internal fun logAction(tag: String, action: BatchAction<Int, TokenMarketListConfig, TokenMarketUpdateRequest>) {
when (action) {
is BatchAction.Reload -> Timber.tag(tag).d(
"""
Reload = ${action.requestParams}
""".trimIndent(),
)
is BatchAction.UpdateBatches -> Timber.tag(tag).d(
"""
To update:
keys: ${action.keys.toList()}
updateType: ${action.updateRequest.javaClass.simpleName}
""".trimIndent(),
)
else -> Timber.tag(tag).d(
"""
$action
""".trimIndent(),
)
}
}
internal fun logUpdateResults(
tag: String,
updateResult: Pair<TokenMarketUpdateRequest, BatchUpdateResult<Int, List<TokenMarket>>>,
) {
val sec = when (val s = updateResult.second) {
is BatchUpdateResult.Success -> "Success"
is BatchUpdateResult.Error -> s.throwable.toString()
}
Timber.tag(tag).d(
"""
updateResults
request: ${updateResult.first}
result: $sec
""".trimIndent(),
)
}

View file

@ -3,12 +3,17 @@ package com.tangem.features.markets.ui
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.Composable
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.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
@ -16,6 +21,7 @@ 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.SpacerH12
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.buttons.SecondarySmallButton
import com.tangem.core.ui.components.buttons.SmallButtonConfig
import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition
@ -27,8 +33,10 @@ 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.components.MarketsListItem
import com.tangem.features.markets.ui.components.MarketsListItemPlaceholder
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.SortByTypeUM
@ -43,6 +51,8 @@ internal fun MarketsList(state: MarketsListUM, onHeaderSizeChange: (Dp) -> Unit,
state = state,
onHeaderSizeChange = onHeaderSizeChange,
)
MarketsListSortByBottomSheet(config = state.sortByBottomSheet)
}
@Composable
@ -121,6 +131,7 @@ private fun Options(
MarketsListUM.TrendInterval.D7,
MarketsListUM.TrendInterval.M1,
),
color = TangemTheme.colors.button.secondary,
initialSelectedItem = trendInterval,
onClick = onIntervalClick,
modifier = Modifier
@ -153,11 +164,12 @@ private fun Items(state: ListUM, modifier: Modifier = Modifier) {
val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() }
LazyColumn(
modifier = modifier,
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 }) {
@ -179,6 +191,70 @@ private fun Items(state: ListUM, modifier: Modifier = Modifier) {
}
}
}
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()
}
}
},
)
}
@Composable
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)
}
}
//region: Preview
@ -196,6 +272,8 @@ private fun Preview() {
item.copy(id = index.toString())
}
.toImmutableList(),
loadMore = {},
visibleIdsChanged = {},
),
searchBar = SearchBarUM(
placeholderText = resourceReference(R.string.manage_tokens_search_placeholder),
@ -208,6 +286,11 @@ private fun Preview() {
selectedInterval = MarketsListUM.TrendInterval.H24,
onIntervalClick = {},
onSortByButtonClick = {},
sortByBottomSheet = TangemBottomSheetConfig(
false,
onDismissRequest = {},
content = SortByBottomSheetContentUM(selectedOption = SortByTypeUM.Rating) {},
),
),
onHeaderSizeChange = {},
)

View file

@ -48,9 +48,6 @@ import com.tangem.features.markets.impl.R
import com.tangem.features.markets.ui.entity.MarketsListItemUM
import com.tangem.features.markets.ui.preview.MarketChartListItemPreviewDataProvider
import com.tangem.utils.StringsSigns.MINUS
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
import kotlin.random.Random
@ -335,22 +332,18 @@ private fun TokenPriceText(price: String, modifier: Modifier = Modifier, priceCh
val color = remember { Animatable(generalColor) }
LaunchedEffect(price, growColor, fallColor, generalColor) {
snapshotFlow { price }
.drop(1)
.distinctUntilChanged()
.collectLatest {
val nextColor = when (priceChangeType) {
PriceChangeType.NEUTRAL,
PriceChangeType.UP,
-> growColor
PriceChangeType.DOWN -> fallColor
null -> generalColor
}
color.animateTo(nextColor, snap())
color.animateTo(generalColor, tween(durationMillis = 500))
LaunchedEffect(price) {
if (priceChangeType != null) {
val nextColor = when (priceChangeType) {
PriceChangeType.UP,
-> growColor
PriceChangeType.DOWN -> fallColor
PriceChangeType.NEUTRAL -> return@LaunchedEffect
}
color.animateTo(nextColor, snap())
color.animateTo(generalColor, tween(durationMillis = 500))
}
}
Text(
@ -369,7 +362,7 @@ private fun Chart(chartType: MarketChartLook.Type, chartRawData: MarketChartRawD
Box(
modifier = Modifier
.padding(vertical = TangemTheme.dimens.spacing2)
.size(height = TangemTheme.dimens.size32, width = chartWidth),
.size(height = TangemTheme.dimens.size24, width = chartWidth),
) {
if (chartRawData != null) {
MarketChartMini(

View file

@ -0,0 +1,88 @@
package com.tangem.features.markets.ui.components
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.inputrow.InputRowChecked
import com.tangem.core.ui.components.inputrow.inner.DividerContainer
import com.tangem.core.ui.components.rows.CornersToRound
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.SortByTypeUM
@Composable
fun MarketsListSortByBottomSheet(config: TangemBottomSheetConfig) {
TangemBottomSheet<SortByBottomSheetContentUM>(
config = config,
titleText = resourceReference(R.string.markets_sort_by_title),
containerColor = TangemTheme.colors.background.tertiary,
content = { Content(it) },
)
}
@Composable
private fun Content(content: SortByBottomSheetContentUM) {
Column(
modifier = Modifier
.padding(
start = TangemTheme.dimens.spacing16,
end = TangemTheme.dimens.spacing16,
bottom = TangemTheme.dimens.spacing16,
),
) {
SortByTypeUM.entries.forEachIndexed { index, type ->
val cornersToRound = when (index) {
0 -> CornersToRound.TOP_2
SortByTypeUM.entries.lastIndex -> CornersToRound.BOTTOM_2
else -> CornersToRound.ZERO
}
DividerContainer(
modifier = Modifier
.clip(cornersToRound.getShape())
.background(TangemTheme.colors.background.action)
.clickable { content.onOptionClicked(type) },
showDivider = index != SortByTypeUM.entries.lastIndex,
) {
InputRowChecked(
text = type.text,
checked = type == content.selectedOption,
)
}
}
}
}
@Preview(widthDp = 360, heightDp = 640)
@Preview(widthDp = 360, heightDp = 640, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview() {
TangemThemePreview(
alwaysShowBottomSheets = true,
) {
Box(Modifier.background(TangemTheme.colors.background.secondary)) {
MarketsListSortByBottomSheet(
TangemBottomSheetConfig(
isShow = true,
onDismissRequest = {},
content = SortByBottomSheetContentUM(
selectedOption = SortByTypeUM.Trending,
onOptionClicked = {},
),
),
)
}
}
}

View file

@ -28,6 +28,6 @@ data class MarketsListItemUM(
@Immutable
data class Price(
val text: String,
val changeType: PriceChangeType = PriceChangeType.NEUTRAL,
val changeType: PriceChangeType? = null,
)
}

View file

@ -1,6 +1,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.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
@ -11,6 +12,7 @@ internal data class MarketsListUM(
val list: ListUM,
val searchBar: SearchBarUM,
val selectedSortBy: SortByTypeUM,
val sortByBottomSheet: TangemBottomSheetConfig,
val selectedInterval: TrendInterval,
val onIntervalClick: (TrendInterval) -> Unit,
val onSortByButtonClick: () -> Unit,
@ -35,6 +37,8 @@ sealed class ListUM {
data class Content(
val items: ImmutableList<MarketsListItemUM>,
val loadMore: () -> Unit,
val visibleIdsChanged: (List<String>) -> Unit,
) : ListUM()
data object Loading : ListUM()