Updated on 2026-08-14
This commit is contained in:
parent
3c8fb7f668
commit
21ef61585b
24 changed files with 1255 additions and 85 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package com.tangem.pagination.exception
|
||||
|
||||
class OperationWIthTheSameIdInProgress(operationId: String) : RuntimeException(
|
||||
"Operation is already in progress - id:$operationId ",
|
||||
)
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
|
|
@ -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 = {},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -28,6 +28,6 @@ data class MarketsListItemUM(
|
|||
@Immutable
|
||||
data class Price(
|
||||
val text: String,
|
||||
val changeType: PriceChangeType = PriceChangeType.NEUTRAL,
|
||||
val changeType: PriceChangeType? = null,
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue