From 21ef61585b8395a4fae6223d9596024374ab336e Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 17 Jul 2024 17:43:48 +0300 Subject: [PATCH] Updated on 2026-08-14 --- app/build.gradle.kts | 2 + .../tap/common/extensions/Navigation.kt | 9 + .../tap/di/domain/MarketsDomainModule.kt | 22 ++ .../com/tangem/datasource/di/NetworkModule.kt | 36 ++- .../com/tangem/pagination/BatchListSource.kt | 59 +++- .../OperationWIthTheSameIdInProgress.kt | 5 + .../ui/components/inputrow/InputRowChecked.kt | 74 +++++ .../inputrow/inner/DividerContainer.kt | 4 +- .../core/ui/utils/BigDecimalFormatter.kt | 74 +++++ .../ui/utils/BigDecimalFormatterCompat.kt | 52 ++++ .../data/markets/MarketsBatchUpdateFetcher.kt | 12 +- features/markets/impl/build.gradle.kts | 5 + .../impl/DefaultMarketsListComponent.kt | 5 + .../markets/model/MarketsListModel.kt | 187 +++++++++--- .../model/SortByBottomSheetContentUM.kt | 9 + .../converters/MarketsTokenItemConverter.kt | 144 +++++++++ .../statemanager/MarketsListUMStateManager.kt | 106 +++++++ .../statemanager/MarketsListUiItemsManager.kt | 275 ++++++++++++++++++ .../markets/model/utils/LoggingUtils.kt | 48 +++ .../tangem/features/markets/ui/MarketsList.kt | 87 +++++- .../markets/ui/components/MarketsListItem.kt | 31 +- .../MarketsListSortByBottomSheet.kt | 88 ++++++ .../markets/ui/entity/MarketsListItemUM.kt | 2 +- .../markets/ui/entity/MarketsListUM.kt | 4 + 24 files changed, 1255 insertions(+), 85 deletions(-) create mode 100644 app/src/main/java/com/tangem/tap/di/domain/MarketsDomainModule.kt create mode 100644 core/pagination/src/main/java/com/tangem/pagination/exception/OperationWIthTheSameIdInProgress.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowChecked.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/utils/BigDecimalFormatterCompat.kt create mode 100644 features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/SortByBottomSheetContentUM.kt create mode 100644 features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/converters/MarketsTokenItemConverter.kt create mode 100644 features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/statemanager/MarketsListUMStateManager.kt create mode 100644 features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/statemanager/MarketsListUiItemsManager.kt create mode 100644 features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/utils/LoggingUtils.kt create mode 100644 features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/components/MarketsListSortByBottomSheet.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c58fa8b592..35083a7d65 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/main/java/com/tangem/tap/common/extensions/Navigation.kt b/app/src/main/java/com/tangem/tap/common/extensions/Navigation.kt index 251edacdf4..5d7a14709f 100644 --- a/app/src/main/java/com/tangem/tap/common/extensions/Navigation.kt +++ b/app/src/main/java/com/tangem/tap/common/extensions/Navigation.kt @@ -8,6 +8,15 @@ import com.tangem.wallet.R import timber.log.Timber fun FragmentManager.showFragmentAllowingStateLoss(name: String, fragmentProvider: Provider) { + 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) diff --git a/app/src/main/java/com/tangem/tap/di/domain/MarketsDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/MarketsDomainModule.kt new file mode 100644 index 0000000000..765e3bcb7f --- /dev/null +++ b/app/src/main/java/com/tangem/tap/di/domain/MarketsDomainModule.kt @@ -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) + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt index 9b2c5b5d29..6951732450 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt @@ -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 = 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 } } \ No newline at end of file diff --git a/core/pagination/src/main/java/com/tangem/pagination/BatchListSource.kt b/core/pagination/src/main/java/com/tangem/pagination/BatchListSource.kt index 1cc865396d..d286dafab8 100644 --- a/core/pagination/src/main/java/com/tangem/pagination/BatchListSource.kt +++ b/core/pagination/src/main/java/com/tangem/pagination/BatchListSource.kt @@ -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 BatchListSource( ): BatchListSource = DefaultBatchListSource(fetchDispatcher, context, generateNewKey, batchFetcher, updateFetcher) +@Suppress("LargeClass") private class DefaultBatchListSource( private val fetchDispatcher: CoroutineDispatcher, private val context: BatchingContext, @@ -90,19 +92,21 @@ private class DefaultBatchListSource 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 loadMoreActionJob?.cancel() reloadActionJob?.cancel() stopAllUpdates() - reloadActionJob = scope.launch(fetchDispatcher) { + reloadActionJob = scope.launchFetch { reloadTask(action) } } @@ -122,15 +126,25 @@ private class DefaultBatchListSource 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 } BatchAction.CancelAllUpdates -> { if (updateFetcher == null) return + stopAllUpdates() } is BatchAction.CancelUpdates -> { @@ -154,9 +169,10 @@ private class DefaultBatchListSource } private fun collectAsyncUpdateAction(action: BatchAction.UpdateBatches) { - 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 private fun collectSyncUpdateAction(action: BatchAction.UpdateBatches) { // 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 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 BatchFetchResult.Error(it) } + currentCoroutineContext().ensureActive() + state.value = when (res) { is BatchFetchResult.Success -> { val key = generateNewKey(listOf()) @@ -306,10 +324,11 @@ private class DefaultBatchListSource 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 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 object : BatchUpdateFetcher.UpdateContext { override suspend fun update(update: List>.() -> BatchUpdateResult) { + currentCoroutineContext().ensureActive() + val stateToFetchUpdateBasedOn = state.value.data.filter { keysToUpdate.contains(it.key) } @@ -349,7 +376,6 @@ private class DefaultBatchListSource val result = runCatching { stateToFetchUpdateBasedOn.update() }.getOrElse { - currentCoroutineContext().ensureActive() BatchUpdateResult.Error(it) } @@ -430,4 +456,11 @@ private class DefaultBatchListSource } } } + + private fun CoroutineScope.launchFetch( + start: CoroutineStart = CoroutineStart.DEFAULT, + block: suspend CoroutineScope.() -> Unit, + ): Job { + return launch(context = fetchDispatcher + SupervisorJob(), start = start, block = block) + } } \ No newline at end of file diff --git a/core/pagination/src/main/java/com/tangem/pagination/exception/OperationWIthTheSameIdInProgress.kt b/core/pagination/src/main/java/com/tangem/pagination/exception/OperationWIthTheSameIdInProgress.kt new file mode 100644 index 0000000000..5cf81ea1cd --- /dev/null +++ b/core/pagination/src/main/java/com/tangem/pagination/exception/OperationWIthTheSameIdInProgress.kt @@ -0,0 +1,5 @@ +package com.tangem.pagination.exception + +class OperationWIthTheSameIdInProgress(operationId: String) : RuntimeException( + "Operation is already in progress - id:$operationId ", +) \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowChecked.kt b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowChecked.kt new file mode 100644 index 0000000000..ca8da83f36 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowChecked.kt @@ -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), + ) + } + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/inner/DividerContainer.kt b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/inner/DividerContainer.kt index a5510a378d..434b7751e7 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/inner/DividerContainer.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/inner/DividerContainer.kt @@ -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), diff --git a/core/ui/src/main/java/com/tangem/core/ui/utils/BigDecimalFormatter.kt b/core/ui/src/main/java/com/tangem/core/ui/utils/BigDecimalFormatter.kt index 4e5256d1d8..e7c941c536 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/utils/BigDecimalFormatter.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/utils/BigDecimalFormatter.kt @@ -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 } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/utils/BigDecimalFormatterCompat.kt b/core/ui/src/main/java/com/tangem/core/ui/utils/BigDecimalFormatterCompat.kt new file mode 100644 index 0000000000..82aab46559 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/utils/BigDecimalFormatterCompat.kt @@ -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, + ) + } +} \ No newline at end of file diff --git a/data/markets/src/main/java/com/tangem/data/markets/MarketsBatchUpdateFetcher.kt b/data/markets/src/main/java/com/tangem/data/markets/MarketsBatchUpdateFetcher.kt index fa24fa4144..625f90797d 100644 --- a/data/markets/src/main/java/com/tangem/data/markets/MarketsBatchUpdateFetcher.kt +++ b/data/markets/src/main/java/com/tangem/data/markets/MarketsBatchUpdateFetcher.kt @@ -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 -> diff --git a/features/markets/impl/build.gradle.kts b/features/markets/impl/build.gradle.kts index 86ae97c792..201feb6d22 100644 --- a/features/markets/impl/build.gradle.kts +++ b/features/markets/impl/build.gradle.kts @@ -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) diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/component/impl/DefaultMarketsListComponent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/component/impl/DefaultMarketsListComponent.kt index 316720a3b6..55757a3a42 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/component/impl/DefaultMarketsListComponent.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/component/impl/DefaultMarketsListComponent.kt @@ -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, diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/MarketsListModel.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/MarketsListModel.kt index 6cbf590bc5..b0d1dac2b3 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/MarketsListModel.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/MarketsListModel.kt @@ -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>(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) + } } \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/SortByBottomSheetContentUM.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/SortByBottomSheetContentUM.kt new file mode 100644 index 0000000000..092b37423a --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/SortByBottomSheetContentUM.kt @@ -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 \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/converters/MarketsTokenItemConverter.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/converters/MarketsTokenItemConverter.kt new file mode 100644 index 0000000000..6366b68d38 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/converters/MarketsTokenItemConverter.kt @@ -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 { + + 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 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, + ) + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/statemanager/MarketsListUMStateManager.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/statemanager/MarketsListUMStateManager.kt new file mode 100644 index 0000000000..052239a869 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/statemanager/MarketsListUMStateManager.kt @@ -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) -> 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) { + 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, + ), + ), + ) + } + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/statemanager/MarketsListUiItemsManager.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/statemanager/MarketsListUiItemsManager.kt new file mode 100644 index 0000000000..53147eb848 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/statemanager/MarketsListUiItemsManager.kt @@ -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, + private val currentAppCurrency: Provider, + private val modelScope: CoroutineScope, + private val dispatchers: CoroutineDispatcherProvider, +) { + private val actionsFlow = MutableSharedFlow>() + + private val batchFlow = getMarketsTokenListFlowUseCase( + TokenListBatchingContext( + actionsFlow = actionsFlow, + coroutineScope = modelScope, + ), + ) + + val uiItems: StateFlow> + 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>>>(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>>?, + list: List>>, + 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, 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): Set { + 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 Flow.onEachWithPrevious(operation: suspend (prev: T?, value: T) -> Unit): Flow = flow { + var prev: T? = null + collect { value -> + operation(prev, value) + prev = value + emit(value) + } + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/utils/LoggingUtils.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/utils/LoggingUtils.kt new file mode 100644 index 0000000000..a7594edf81 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/utils/LoggingUtils.kt @@ -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) { + 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>>, +) { + 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(), + ) +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/MarketsList.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/MarketsList.kt index 7929779ddb..4f61361468 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/MarketsList.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/MarketsList.kt @@ -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 = {}, ) diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/components/MarketsListItem.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/components/MarketsListItem.kt index 5bc81ee9f6..847f063309 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/components/MarketsListItem.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/components/MarketsListItem.kt @@ -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( diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/components/MarketsListSortByBottomSheet.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/components/MarketsListSortByBottomSheet.kt new file mode 100644 index 0000000000..ae2a87f4a0 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/components/MarketsListSortByBottomSheet.kt @@ -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( + 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 = {}, + ), + ), + ) + } + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/entity/MarketsListItemUM.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/entity/MarketsListItemUM.kt index a9f12c9a19..a6f2c0c038 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/entity/MarketsListItemUM.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/entity/MarketsListItemUM.kt @@ -28,6 +28,6 @@ data class MarketsListItemUM( @Immutable data class Price( val text: String, - val changeType: PriceChangeType = PriceChangeType.NEUTRAL, + val changeType: PriceChangeType? = null, ) } \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/entity/MarketsListUM.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/entity/MarketsListUM.kt index b431e9e54c..602191058c 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/entity/MarketsListUM.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/entity/MarketsListUM.kt @@ -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, + val loadMore: () -> Unit, + val visibleIdsChanged: (List) -> Unit, ) : ListUM() data object Loading : ListUM()