From 3f671e3e0f0f4da0ece0e120162860cd1a20e4d5 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 5 Mar 2025 13:16:45 +0500 Subject: [PATCH] Updated on 2026-08-14 --- app/build.gradle.kts | 2 + .../configs/feature_toggles_config.json | 4 + .../com/tangem/pagination/BatchListSource.kt | 2 +- .../ui/components/transactions/Transaction.kt | 1 + .../transactions/TransactionList.kt | 2 +- .../components/transactions/TxHistoryTitle.kt | 2 +- data/txhistory/build.gradle.kts | 1 + .../data/txhistory/di/TxHistoryDataModule.kt | 16 ++ .../RefactoredTxHistoryRepository.kt | 126 +++++++++++ .../paging/TxHistoryPageBatchFetcher.kt | 73 +++++++ domain/txhistory/build.gradle.kts | 2 + .../tangem/domain/txhistory/models/Page.kt | 4 +- .../txhistory/model/TxHistoryListConfig.kt | 6 + .../txhistory/model/TxHistoryTypealiases.kt | 10 + .../repository/TxHistoryRepositoryV2.kt | 9 + features/send/impl/build.gradle.kts | 1 + .../send/impl/presentation/model/SendModel.kt | 18 +- features/staking/impl/build.gradle.kts | 1 + .../state/helpers/StakingBalanceUpdater.kt | 18 +- features/tokendetails/impl/build.gradle.kts | 1 + .../DefaultTokenDetailsComponent.kt | 14 +- .../tokendetails/model/TokenDetailsModel.kt | 52 +++-- .../tokendetails/ui/TokenDetailsScreen.kt | 48 ++++- features/txhistory/api/.gitignore | 1 + features/txhistory/api/build.gradle.kts | 26 +++ .../txhistory/TxHistoryFeatureToggles.kt | 5 + .../txhistory/component/TxHistoryComponent.kt | 28 +++ .../entity/TxHistoryContentUpdateEmitter.kt | 5 + .../features/txhistory/entity/TxHistoryUM.kt | 93 ++++++++ features/txhistory/impl/.gitignore | 1 + features/txhistory/impl/build.gradle.kts | 59 +++++ .../DefaultTxHistoryFeatureToggles.kt | 11 + .../component/DefaultTxHistoryComponent.kt | 38 ++++ ...xHistoryItemToTransactionStateConverter.kt | 129 +++++++++++ .../txhistory/di/TxHistoryFeatureModule.kt | 23 ++ .../txhistory/di/TxHistoryModelModule.kt | 19 ++ .../txhistory/di/TxHistoryUpdaterModule.kt | 23 ++ .../entity/DefaultTxHistoryUpdater.kt | 18 ++ .../entity/TxHistoryUpdateListener.kt | 7 + .../txhistory/model/TxHistoryModel.kt | 202 ++++++++++++++++++ .../features/txhistory/ui/TxHistoryContent.kt | 164 ++++++++++++++ .../txhistory/utils/TxHistoryListManager.kt | 99 +++++++++ .../txhistory/utils/TxHistoryListState.kt | 10 + .../txhistory/utils/TxHistoryUiActions.kt | 7 + .../txhistory/utils/TxHistoryUiManager.kt | 113 ++++++++++ settings.gradle.kts | 3 + 46 files changed, 1454 insertions(+), 43 deletions(-) create mode 100644 data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/RefactoredTxHistoryRepository.kt create mode 100644 data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/paging/TxHistoryPageBatchFetcher.kt create mode 100644 domain/txhistory/src/main/kotlin/com/tangem/domain/txhistory/model/TxHistoryListConfig.kt create mode 100644 domain/txhistory/src/main/kotlin/com/tangem/domain/txhistory/model/TxHistoryTypealiases.kt create mode 100644 domain/txhistory/src/main/kotlin/com/tangem/domain/txhistory/repository/TxHistoryRepositoryV2.kt create mode 100644 features/txhistory/api/.gitignore create mode 100644 features/txhistory/api/build.gradle.kts create mode 100644 features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/TxHistoryFeatureToggles.kt create mode 100644 features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/component/TxHistoryComponent.kt create mode 100644 features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryContentUpdateEmitter.kt create mode 100644 features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryUM.kt create mode 100644 features/txhistory/impl/.gitignore create mode 100644 features/txhistory/impl/build.gradle.kts create mode 100644 features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/DefaultTxHistoryFeatureToggles.kt create mode 100644 features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/component/DefaultTxHistoryComponent.kt create mode 100644 features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryItemToTransactionStateConverter.kt create mode 100644 features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/di/TxHistoryFeatureModule.kt create mode 100644 features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/di/TxHistoryModelModule.kt create mode 100644 features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/di/TxHistoryUpdaterModule.kt create mode 100644 features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/entity/DefaultTxHistoryUpdater.kt create mode 100644 features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryUpdateListener.kt create mode 100644 features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryModel.kt create mode 100644 features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryContent.kt create mode 100644 features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryListManager.kt create mode 100644 features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryListState.kt create mode 100644 features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryUiActions.kt create mode 100644 features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryUiManager.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5c9baecf6c..6f39ce42e9 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -186,6 +186,8 @@ dependencies { implementation(projects.features.onboardingV2.impl) implementation(projects.features.stories.api) implementation(projects.features.stories.impl) + implementation(projects.features.txhistory.api) + implementation(projects.features.txhistory.impl) /** AndroidX libraries */ implementation(deps.androidx.core.ktx) diff --git a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json index 0538c8067c..a9a8c23e01 100644 --- a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json +++ b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json @@ -54,5 +54,9 @@ { "name": "STAKING_CARDANO_ENABLED", "version": "undefined" + }, + { + "name": "TX_HISTORY_REFACTORING_ENABLED", + "version": "undefined" } ] 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 535c7deea4..dab020caa2 100644 --- a/core/pagination/src/main/java/com/tangem/pagination/BatchListSource.kt +++ b/core/pagination/src/main/java/com/tangem/pagination/BatchListSource.kt @@ -289,7 +289,7 @@ private class DefaultBatchListSource batchFetcher.fetchNext(action.requestParams, lastResult) }.getOrElse { BatchFetchResult.Error(it) } - lastRequestResult.value = lastResult + lastRequestResult.value = res state.update { currentState -> when (res) { diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/transactions/Transaction.kt b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/Transaction.kt index 8c7bdbc6d9..761db501ac 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/transactions/Transaction.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/Transaction.kt @@ -445,5 +445,6 @@ private class TransactionItemStateProvider : CollectionPreviewParameterProvider< onClick = {}, ), TransactionState.Loading(txHash = UUID.randomUUID().toString()), + TransactionState.Locked(txHash = UUID.randomUUID().toString()), ), ) \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TransactionList.kt b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TransactionList.kt index 7aeca782bf..5ff2f3c7bc 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TransactionList.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TransactionList.kt @@ -104,7 +104,7 @@ private fun LazyListScope.contentItems( } @Composable -private fun PendingTxsBlock(pendingTxs: ImmutableList, isBalanceHidden: Boolean) { +fun PendingTxsBlock(pendingTxs: ImmutableList, isBalanceHidden: Boolean) { Column( modifier = Modifier .padding(top = TangemTheme.dimens.spacing12) diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TxHistoryTitle.kt b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TxHistoryTitle.kt index 4f8201f07c..f7cb3263ce 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TxHistoryTitle.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TxHistoryTitle.kt @@ -23,7 +23,7 @@ import com.tangem.core.ui.res.TangemThemePreview * @param modifier modifier */ @Composable -internal fun TxHistoryTitle(onExploreClick: () -> Unit, modifier: Modifier = Modifier) { +fun TxHistoryTitle(onExploreClick: () -> Unit, modifier: Modifier = Modifier) { Row( modifier = modifier .background(TangemTheme.colors.background.primary) diff --git a/data/txhistory/build.gradle.kts b/data/txhistory/build.gradle.kts index 23bb1bd73d..0a6a9baf74 100644 --- a/data/txhistory/build.gradle.kts +++ b/data/txhistory/build.gradle.kts @@ -14,6 +14,7 @@ dependencies { implementation(projects.core.utils) implementation(projects.core.datasource) + implementation(projects.core.pagination) implementation(projects.domain.legacy) implementation(projects.libs.blockchainSdk) implementation(projects.domain.tokens.models) diff --git a/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/di/TxHistoryDataModule.kt b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/di/TxHistoryDataModule.kt index ebc92a5036..1a756f54e1 100644 --- a/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/di/TxHistoryDataModule.kt +++ b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/di/TxHistoryDataModule.kt @@ -2,9 +2,11 @@ package com.tangem.data.txhistory.di import com.tangem.data.common.cache.CacheRegistry import com.tangem.data.txhistory.repository.DefaultTxHistoryRepository +import com.tangem.data.txhistory.repository.RefactoredTxHistoryRepository import com.tangem.datasource.local.txhistory.TxHistoryItemsStore import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.domain.txhistory.repository.TxHistoryRepository +import com.tangem.domain.txhistory.repository.TxHistoryRepositoryV2 import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module @@ -32,4 +34,18 @@ internal object TxHistoryDataModule { txHistoryItemsStore, dispatchers, ) + + @Provides + @Singleton + fun provideTxHistoryRepositoryV2( + walletManagersFacade: WalletManagersFacade, + dispatchers: CoroutineDispatcherProvider, + txHistoryItemsStore: TxHistoryItemsStore, + cacheRegistry: CacheRegistry, + ): TxHistoryRepositoryV2 = RefactoredTxHistoryRepository( + walletManagersFacade = walletManagersFacade, + dispatchers = dispatchers, + txHistoryItemsStore = txHistoryItemsStore, + cacheRegistry = cacheRegistry, + ) } \ No newline at end of file diff --git a/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/RefactoredTxHistoryRepository.kt b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/RefactoredTxHistoryRepository.kt new file mode 100644 index 0000000000..97c64d5f55 --- /dev/null +++ b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/RefactoredTxHistoryRepository.kt @@ -0,0 +1,126 @@ +package com.tangem.data.txhistory.repository + +import com.tangem.data.common.cache.CacheRegistry +import com.tangem.data.txhistory.repository.paging.TxHistoryPageBatchFetcher +import com.tangem.datasource.local.txhistory.TxHistoryItemsStore +import com.tangem.domain.txhistory.model.TxHistoryListBatchFlow +import com.tangem.domain.txhistory.model.TxHistoryListBatchingContext +import com.tangem.domain.txhistory.model.TxHistoryListConfig +import com.tangem.domain.txhistory.models.Page +import com.tangem.domain.txhistory.models.PaginationWrapper +import com.tangem.domain.txhistory.models.TxHistoryItem +import com.tangem.domain.txhistory.repository.TxHistoryRepositoryV2 +import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.domain.walletmanager.utils.SdkPageConverter +import com.tangem.pagination.BatchFetchResult +import com.tangem.pagination.BatchListSource +import com.tangem.pagination.toBatchFlow +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import timber.log.Timber + +internal class RefactoredTxHistoryRepository( + private val walletManagersFacade: WalletManagersFacade, + private val txHistoryItemsStore: TxHistoryItemsStore, + private val cacheRegistry: CacheRegistry, + private val dispatchers: CoroutineDispatcherProvider, +) : TxHistoryRepositoryV2 { + + private val sdkPageConverter = SdkPageConverter() + private val TxHistoryListConfig.storeKey get() = TxHistoryItemsStore.Key(userWalletId, currency) + + override fun getTxHistoryBatchFlow(batchSize: Int, context: TxHistoryListBatchingContext): TxHistoryListBatchFlow { + return BatchListSource( + fetchDispatcher = dispatchers.io, + context = context, + generateNewKey = { keys -> keys.lastOrNull()?.inc() ?: 0 }, + batchFetcher = createFetcher(batchSize), + ).toBatchFlow() + } + + private fun createFetcher( + batchSize: Int, + ): TxHistoryPageBatchFetcher> = + TxHistoryPageBatchFetcher { request, _ -> + val wrappedItems = loadItems(request, batchSize) + BatchFetchResult.Success( + data = wrappedItems, + empty = wrappedItems.items.isEmpty(), + last = wrappedItems.nextPage is Page.LastPage, + ) + } + + private suspend fun loadItems( + request: TxHistoryPageBatchFetcher.Request, + batchSize: Int, + ): PaginationWrapper { + cacheRegistry.invokeOnExpire( + key = getTxHistoryPageKey(request.page, request.params), + skipCache = request.params.refresh, + block = { fetch(request, batchSize) }, + ) + + return txHistoryItemsStore.getSync(request.page, request.params) + } + + private suspend fun fetch(request: TxHistoryPageBatchFetcher.Request, batchSize: Int) { + val wrappedItems = walletManagersFacade.getTxHistoryItems( + userWalletId = request.params.userWalletId, + currency = request.params.currency, + page = sdkPageConverter.convertBack(request.page), + pageSize = batchSize, + ) + + txHistoryItemsStore.store(key = request.params.storeKey, value = wrappedItems) + } + + private suspend fun TxHistoryItemsStore.getSync( + pageToLoad: Page, + config: TxHistoryListConfig, + ): PaginationWrapper { + val storedItems = requireNotNull(getSyncOrNull(config.storeKey, pageToLoad)) { + "The transaction history page #$pageToLoad could not be retrieved" + } + + return if (pageToLoad is Page.Initial) storedItems.addRecentTransactions(config) else storedItems + } + + private suspend fun PaginationWrapper.addRecentTransactions( + config: TxHistoryListConfig, + ): PaginationWrapper { + val recentItems = walletManagersFacade.getRecentTransactions( + userWalletId = config.userWalletId, + currency = config.currency, + ) + .filterUnconfirmedTransaction() + .sortedByDescending { it.timestampInMillis } + .filterIfTxAlreadyAdded(apiItems = items) + + return if (recentItems.isEmpty()) { + Timber.d("Nothing to add to TxHistory") + this + } else { + Timber.d( + "Recent transactions were added to TxHistory: %s", + recentItems.joinToString( + prefix = "[", + postfix = "]", + transform = TxHistoryItem::txHash, + ), + ) + + return copy(items = recentItems + items) + } + } + + private fun List.filterUnconfirmedTransaction(): List { + return filter { it.status == TxHistoryItem.TransactionStatus.Unconfirmed } + } + + private fun List.filterIfTxAlreadyAdded(apiItems: List): List { + return filter { item -> apiItems.none { it.txHash == item.txHash } } + } + + private fun getTxHistoryPageKey(page: Page, config: TxHistoryListConfig): String { + return "tx_history_page_${config.currency}_${config.userWalletId}_$page" + } +} \ No newline at end of file diff --git a/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/paging/TxHistoryPageBatchFetcher.kt b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/paging/TxHistoryPageBatchFetcher.kt new file mode 100644 index 0000000000..429f0a6f30 --- /dev/null +++ b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/paging/TxHistoryPageBatchFetcher.kt @@ -0,0 +1,73 @@ +package com.tangem.data.txhistory.repository.paging + +import com.tangem.domain.txhistory.models.Page +import com.tangem.domain.txhistory.models.PaginationWrapper +import com.tangem.domain.txhistory.models.TxHistoryItem +import com.tangem.pagination.BatchFetchResult +import com.tangem.pagination.exception.EndOfPaginationException +import com.tangem.pagination.fetcher.BatchFetcher +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableStateFlow + +internal class TxHistoryPageBatchFetcher>( + private val subFetcher: SubFetcher, +) : BatchFetcher { + data class Request(val page: Page, val params: TRequestParams) + fun interface SubFetcher { + suspend fun fetch( + request: Request, + lastResult: BatchFetchResult?, + ): BatchFetchResult + } + + private val lastRequest = MutableStateFlow?>(null) + + override suspend fun fetchFirst(requestParams: TRequestParams): BatchFetchResult { + val req = Request( + page = Page.Initial, + params = requestParams, + ) + + val res = runCatching { + subFetcher.fetch(request = req, lastResult = null) + }.getOrElse { + currentCoroutineContext().ensureActive() + BatchFetchResult.Error(it) + } + + lastRequest.value = req + return res + } + + override suspend fun fetchNext( + overrideRequestParams: TRequestParams?, + lastResult: BatchFetchResult, + ): BatchFetchResult { + val last = lastRequest.value + requireNotNull(last) + + val req = if (lastResult is BatchFetchResult.Success) { + if (lastResult.last && overrideRequestParams == null) { + return BatchFetchResult.Error(EndOfPaginationException()) + } + + Request( + page = lastResult.data.nextPage, + params = overrideRequestParams ?: last.params, + ) + } else { + last + } + + val res = runCatching { + subFetcher.fetch(request = req, lastResult = lastResult) + }.getOrElse { + currentCoroutineContext().ensureActive() + BatchFetchResult.Error(it) + } + + lastRequest.value = req + return res + } +} \ No newline at end of file diff --git a/domain/txhistory/build.gradle.kts b/domain/txhistory/build.gradle.kts index 892e55e639..269f30a989 100644 --- a/domain/txhistory/build.gradle.kts +++ b/domain/txhistory/build.gradle.kts @@ -20,4 +20,6 @@ dependencies { /** Android - Other */ implementation(deps.androidx.paging.runtime) + + api(projects.core.pagination) } \ No newline at end of file diff --git a/domain/txhistory/models/src/main/kotlin/com/tangem/domain/txhistory/models/Page.kt b/domain/txhistory/models/src/main/kotlin/com/tangem/domain/txhistory/models/Page.kt index 9e18e15403..4633490788 100644 --- a/domain/txhistory/models/src/main/kotlin/com/tangem/domain/txhistory/models/Page.kt +++ b/domain/txhistory/models/src/main/kotlin/com/tangem/domain/txhistory/models/Page.kt @@ -1,7 +1,7 @@ package com.tangem.domain.txhistory.models sealed class Page { - object Initial : Page() + data object Initial : Page() data class Next(val value: String) : Page() - object LastPage : Page() + data object LastPage : Page() } \ No newline at end of file diff --git a/domain/txhistory/src/main/kotlin/com/tangem/domain/txhistory/model/TxHistoryListConfig.kt b/domain/txhistory/src/main/kotlin/com/tangem/domain/txhistory/model/TxHistoryListConfig.kt new file mode 100644 index 0000000000..303b464ff3 --- /dev/null +++ b/domain/txhistory/src/main/kotlin/com/tangem/domain/txhistory/model/TxHistoryListConfig.kt @@ -0,0 +1,6 @@ +package com.tangem.domain.txhistory.model + +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.wallets.models.UserWalletId + +data class TxHistoryListConfig(val userWalletId: UserWalletId, val currency: CryptoCurrency, val refresh: Boolean) \ No newline at end of file diff --git a/domain/txhistory/src/main/kotlin/com/tangem/domain/txhistory/model/TxHistoryTypealiases.kt b/domain/txhistory/src/main/kotlin/com/tangem/domain/txhistory/model/TxHistoryTypealiases.kt new file mode 100644 index 0000000000..334b31fc10 --- /dev/null +++ b/domain/txhistory/src/main/kotlin/com/tangem/domain/txhistory/model/TxHistoryTypealiases.kt @@ -0,0 +1,10 @@ +package com.tangem.domain.txhistory.model + +import com.tangem.domain.txhistory.models.PaginationWrapper +import com.tangem.domain.txhistory.models.TxHistoryItem +import com.tangem.pagination.BatchFlow +import com.tangem.pagination.BatchingContext + +typealias TxHistoryListBatchingContext = BatchingContext + +typealias TxHistoryListBatchFlow = BatchFlow, Nothing> \ No newline at end of file diff --git a/domain/txhistory/src/main/kotlin/com/tangem/domain/txhistory/repository/TxHistoryRepositoryV2.kt b/domain/txhistory/src/main/kotlin/com/tangem/domain/txhistory/repository/TxHistoryRepositoryV2.kt new file mode 100644 index 0000000000..ae9ea875ea --- /dev/null +++ b/domain/txhistory/src/main/kotlin/com/tangem/domain/txhistory/repository/TxHistoryRepositoryV2.kt @@ -0,0 +1,9 @@ +package com.tangem.domain.txhistory.repository + +import com.tangem.domain.txhistory.model.TxHistoryListBatchFlow +import com.tangem.domain.txhistory.model.TxHistoryListBatchingContext + +interface TxHistoryRepositoryV2 { + + fun getTxHistoryBatchFlow(batchSize: Int, context: TxHistoryListBatchingContext): TxHistoryListBatchFlow +} \ No newline at end of file diff --git a/features/send/impl/build.gradle.kts b/features/send/impl/build.gradle.kts index 7df0d26ab3..8883699dbb 100644 --- a/features/send/impl/build.gradle.kts +++ b/features/send/impl/build.gradle.kts @@ -84,6 +84,7 @@ dependencies { /** Feature modules */ implementation(projects.features.send.api) implementation(projects.features.tokendetails.api) + implementation(projects.features.txhistory.api) implementation(projects.features.qrScanning.api) /** DI */ diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/model/SendModel.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/model/SendModel.kt index 91af0581fc..55e0edc294 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/model/SendModel.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/model/SendModel.kt @@ -60,6 +60,8 @@ import com.tangem.features.send.impl.presentation.state.amount.AmountStateFactor import com.tangem.features.send.impl.presentation.state.confirm.SendNotificationFactory import com.tangem.features.send.impl.presentation.state.fee.* import com.tangem.features.send.impl.presentation.state.recipient.RecipientSendFactory +import com.tangem.features.txhistory.TxHistoryFeatureToggles +import com.tangem.features.txhistory.entity.TxHistoryContentUpdateEmitter import com.tangem.lib.crypto.BlockchainUtils import com.tangem.utils.Provider import com.tangem.utils.coroutines.* @@ -110,6 +112,8 @@ internal class SendModel @Inject constructor( private val getCardInfoUseCase: GetCardInfoUseCase, private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, private val shareManager: ShareManager, + private val txHistoryFeatureToggles: TxHistoryFeatureToggles, + private val txHistoryContentUpdateEmitter: TxHistoryContentUpdateEmitter, @DelayedWork private val coroutineScope: CoroutineScope, private val innerRouter: InnerSendRouter, private val appRouter: AppRouter, @@ -1012,11 +1016,15 @@ internal class SendModel @Inject constructor( ) txHistoryItemsCountEither.onRight { - getTxHistoryItemsUseCase( - userWalletId = userWalletId, - currency = cryptoCurrency, - refresh = true, - ) + if (txHistoryFeatureToggles.isFeatureEnabled) { + txHistoryContentUpdateEmitter.triggerUpdate() + } else { + getTxHistoryItemsUseCase( + userWalletId = userWalletId, + currency = cryptoCurrency, + refresh = true, + ) + } } } diff --git a/features/staking/impl/build.gradle.kts b/features/staking/impl/build.gradle.kts index bbfa41e6e2..6ffb00cc29 100644 --- a/features/staking/impl/build.gradle.kts +++ b/features/staking/impl/build.gradle.kts @@ -75,6 +75,7 @@ dependencies { /** Feature modules */ implementation(projects.features.staking.api) + implementation(projects.features.txhistory.api) /** DI */ implementation(deps.hilt.android) diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingBalanceUpdater.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingBalanceUpdater.kt index db7d970087..6c26ecf208 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingBalanceUpdater.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingBalanceUpdater.kt @@ -10,6 +10,8 @@ import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsCountUseCase import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsUseCase import com.tangem.domain.wallets.models.UserWallet +import com.tangem.features.txhistory.TxHistoryFeatureToggles +import com.tangem.features.txhistory.entity.TxHistoryContentUpdateEmitter import com.tangem.utils.coroutines.DelayedWork import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -24,6 +26,8 @@ internal class StakingBalanceUpdater @AssistedInject constructor( private val getTxHistoryItemsCountUseCase: GetTxHistoryItemsCountUseCase, private val getTxHistoryItemsUseCase: GetTxHistoryItemsUseCase, private val fetchActionsUseCase: FetchActionsUseCase, + private val txHistoryFeatureToggles: TxHistoryFeatureToggles, + private val txHistoryContentUpdateEmitter: TxHistoryContentUpdateEmitter, @DelayedWork private val coroutineScope: CoroutineScope, @Assisted private val userWallet: UserWallet, @Assisted private val cryptoCurrencyStatus: CryptoCurrencyStatus, @@ -97,11 +101,15 @@ internal class StakingBalanceUpdater @AssistedInject constructor( ) txHistoryItemsCountEither.onRight { - getTxHistoryItemsUseCase( - userWalletId = userWallet.walletId, - currency = cryptoCurrencyStatus.currency, - refresh = true, - ) + if (txHistoryFeatureToggles.isFeatureEnabled) { + txHistoryContentUpdateEmitter.triggerUpdate() + } else { + getTxHistoryItemsUseCase( + userWalletId = userWallet.walletId, + currency = cryptoCurrencyStatus.currency, + refresh = true, + ) + } } } diff --git a/features/tokendetails/impl/build.gradle.kts b/features/tokendetails/impl/build.gradle.kts index 1457affebd..e725d7833a 100644 --- a/features/tokendetails/impl/build.gradle.kts +++ b/features/tokendetails/impl/build.gradle.kts @@ -99,5 +99,6 @@ dependencies { implementation(projects.features.markets.api) implementation(projects.features.onramp.api) implementation(projects.features.swap.api) + implementation(projects.features.txhistory.api) } \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsComponent.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsComponent.kt index 5d4274694f..b89234b7b6 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsComponent.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsComponent.kt @@ -17,6 +17,8 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDeta import com.tangem.feature.tokendetails.presentation.tokendetails.ui.TokenDetailsScreen import com.tangem.features.markets.token.block.TokenMarketBlockComponent import com.tangem.features.tokendetails.TokenDetailsComponent +import com.tangem.features.txhistory.TxHistoryFeatureToggles +import com.tangem.features.txhistory.component.TxHistoryComponent import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -25,10 +27,20 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor( @Assisted appComponentContext: AppComponentContext, @Assisted params: TokenDetailsComponent.Params, tokenMarketBlockComponentFactory: TokenMarketBlockComponent.Factory, + txHistoryComponentFactory: TxHistoryComponent.Factory, + txHistoryFeatureToggles: TxHistoryFeatureToggles, deepLinksRegistry: DeepLinksRegistry, ) : TokenDetailsComponent, AppComponentContext by appComponentContext { private val model: TokenDetailsModel = getOrCreateModel(params) + private val txHistoryComponent = txHistoryComponentFactory.create( + context = child("txHistoryComponent"), + params = TxHistoryComponent.Params( + userWalletId = params.userWalletId, + currency = params.currency, + openExplorer = { model.onExploreClick() }, + ), + ).takeIf { txHistoryFeatureToggles.isFeatureEnabled } init { lifecycle.subscribe( @@ -54,11 +66,11 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor( @Composable override fun Content(modifier: Modifier) { val state by model.uiState.collectAsStateWithLifecycle() - NavigationBar3ButtonsScrim() TokenDetailsScreen( state = state, tokenMarketBlockComponent = tokenMarketBlockComponent, + txHistoryComponent = txHistoryComponent, ) } diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt index 545c8d7f8e..c166236643 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt @@ -10,8 +10,8 @@ import com.tangem.common.ui.expressStatus.ExpressStatusBottomSheetConfig import com.tangem.common.ui.expressStatus.state.ExpressTransactionStateUM import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.models.AnalyticsParam -import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.di.GlobalUiMessageSender +import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.ui.UiMessageSender @@ -76,6 +76,8 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.e import com.tangem.features.onramp.OnrampFeatureToggles import com.tangem.features.tokendetails.TokenDetailsComponent import com.tangem.features.tokendetails.impl.R +import com.tangem.features.txhistory.TxHistoryFeatureToggles +import com.tangem.features.txhistory.entity.TxHistoryContentUpdateEmitter import com.tangem.utils.Provider import com.tangem.utils.coroutines.* import kotlinx.collections.immutable.PersistentList @@ -122,6 +124,8 @@ internal class TokenDetailsModel @Inject constructor( private val onrampFeatureToggles: OnrampFeatureToggles, private val shareManager: ShareManager, @GlobalUiMessageSender private val uiMessageSender: UiMessageSender, + private val txHistoryFeatureToggles: TxHistoryFeatureToggles, + private val txHistoryContentUpdateEmitter: TxHistoryContentUpdateEmitter, paramsContainer: ParamsContainer, expressStatusFactory: ExpressStatusFactory.Factory, getUserWalletUseCase: GetUserWalletUseCase, @@ -240,7 +244,7 @@ internal class TokenDetailsModel @Inject constructor( private fun updateContent() { subscribeOnCurrencyStatusUpdates() subscribeOnExpressTransactionsUpdates() - updateTxHistory(refresh = false, showItemsLoading = true) + updateTxHistory(refresh = false, showItemsLoading = true, initialUpdating = true) updateStakingInfo() } @@ -362,29 +366,33 @@ internal class TokenDetailsModel @Inject constructor( * @param refresh - invalidate cache and get data from remote * @param showItemsLoading - show loading items placeholder. */ - private fun updateTxHistory(refresh: Boolean, showItemsLoading: Boolean) { - modelScope.launch(dispatchers.main) { - val txHistoryItemsCountEither = txHistoryItemsCountUseCase( - userWalletId = userWalletId, - currency = cryptoCurrency, - ) - - // if countEither is left, handling error state run inside getLoadingTxHistoryState - if (showItemsLoading || txHistoryItemsCountEither.isLeft()) { - internalUiState.value = stateFactory.getLoadingTxHistoryState( - itemsCountEither = txHistoryItemsCountEither, - pendingTransactions = internalUiState.value.pendingTxs, - ) - } - - txHistoryItemsCountEither.onRight { - val maybeTxHistory = txHistoryItemsUseCase( + private fun updateTxHistory(refresh: Boolean, showItemsLoading: Boolean, initialUpdating: Boolean = false) { + if (txHistoryFeatureToggles.isFeatureEnabled && !initialUpdating) { + modelScope.launch { txHistoryContentUpdateEmitter.triggerUpdate() } + } else { + modelScope.launch(dispatchers.main) { + val txHistoryItemsCountEither = txHistoryItemsCountUseCase( userWalletId = userWalletId, currency = cryptoCurrency, - refresh = refresh, - ).map { it.cachedIn(modelScope) } + ) - internalUiState.value = stateFactory.getLoadedTxHistoryState(maybeTxHistory) + // if countEither is left, handling error state run inside getLoadingTxHistoryState + if (showItemsLoading || txHistoryItemsCountEither.isLeft()) { + internalUiState.value = stateFactory.getLoadingTxHistoryState( + itemsCountEither = txHistoryItemsCountEither, + pendingTransactions = internalUiState.value.pendingTxs, + ) + } + + txHistoryItemsCountEither.onRight { + val maybeTxHistory = txHistoryItemsUseCase( + userWalletId = userWalletId, + currency = cryptoCurrency, + refresh = refresh, + ).map { it.cachedIn(modelScope) } + + internalUiState.value = stateFactory.getLoadedTxHistoryState(maybeTxHistory) + } } } } diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt index 29775149aa..f315753e17 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt @@ -3,16 +3,18 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.ui import android.content.res.Configuration import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.* import androidx.compose.material3.Scaffold import androidx.compose.material3.ScaffoldDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.State import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.collectAsLazyPagingItems import com.tangem.common.ui.expressStatus.ExpressStatusBottomSheetConfig import com.tangem.common.ui.expressStatus.expressTransactionsItems @@ -39,11 +41,18 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.T import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.express.ExpressStatusBottomSheet import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.staking.TokenStakingBlock import com.tangem.features.markets.token.block.TokenMarketBlockComponent +import com.tangem.features.txhistory.component.TxHistoryComponent +import com.tangem.features.txhistory.entity.TxHistoryUM +import kotlin.reflect.KProperty // TODO: Split to blocks [REDACTED_JIRA] @Suppress("LongMethod") @Composable -internal fun TokenDetailsScreen(state: TokenDetailsState, tokenMarketBlockComponent: TokenMarketBlockComponent?) { +internal fun TokenDetailsScreen( + state: TokenDetailsState, + tokenMarketBlockComponent: TokenMarketBlockComponent?, + txHistoryComponent: TxHistoryComponent?, +) { BackHandler(onBack = state.topAppBarConfig.onBackClick) val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() } @@ -57,6 +66,8 @@ internal fun TokenDetailsScreen(state: TokenDetailsState, tokenMarketBlockCompon } else { null } + val listState = rememberLazyListState() + val txHistoryComponentState by txHistoryComponent?.txHistoryState?.collectAsStateWithLifecycle() val betweenItemsPadding = TangemTheme.dimens.spacing12 val horizontalPadding = TangemTheme.dimens.spacing16 val itemModifier = Modifier @@ -69,6 +80,7 @@ internal fun TokenDetailsScreen(state: TokenDetailsState, tokenMarketBlockCompon ) { LazyColumn( modifier = Modifier.fillMaxSize(), + state = listState, contentPadding = PaddingValues( bottom = TangemTheme.dimens.spacing16 + bottomBarHeight, ), @@ -145,9 +157,12 @@ internal fun TokenDetailsScreen(state: TokenDetailsState, tokenMarketBlockCompon ) txHistoryItems( - state = state.txHistoryState, - isBalanceHidden = state.isBalanceHidden, + listState = listState, + txHistoryComponent = txHistoryComponent, + txHistoryComponentState = txHistoryComponentState, + txHistoryState = state.txHistoryState, txHistoryItems = txHistoryItems, + isBalanceHidden = state.isBalanceHidden, ) } } @@ -170,6 +185,28 @@ internal fun TokenDetailsScreen(state: TokenDetailsState, tokenMarketBlockCompon } } +@Suppress("LongParameterList") +private fun LazyListScope.txHistoryItems( + listState: LazyListState, + txHistoryComponent: TxHistoryComponent?, + txHistoryComponentState: TxHistoryUM?, + txHistoryState: TxHistoryState, + txHistoryItems: LazyPagingItems?, + isBalanceHidden: Boolean, +) { + if (txHistoryComponent != null && txHistoryComponentState != null) { + with(txHistoryComponent) { txHistoryContent(listState = listState, state = txHistoryComponentState) } + } else { + txHistoryItems( + state = txHistoryState, + isBalanceHidden = isBalanceHidden, + txHistoryItems = txHistoryItems, + ) + } +} + +private inline operator fun State?.getValue(thisObj: Any?, property: KProperty<*>): T? = this?.value + // region Preview @Preview(showBackground = true, widthDp = 360) @Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) @@ -181,6 +218,7 @@ private fun TokenDetailsScreenPreview( TokenDetailsScreen( state = state, tokenMarketBlockComponent = null, + txHistoryComponent = null, ) } } diff --git a/features/txhistory/api/.gitignore b/features/txhistory/api/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/features/txhistory/api/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/features/txhistory/api/build.gradle.kts b/features/txhistory/api/build.gradle.kts new file mode 100644 index 0000000000..7b0b2e9ff0 --- /dev/null +++ b/features/txhistory/api/build.gradle.kts @@ -0,0 +1,26 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + id("configuration") +} + +android { + namespace = "com.tangem.features.txhistory.api" +} + +dependencies { + /** Project - Core */ + implementation(projects.core.ui) + implementation(projects.core.decompose) + + /** Domain models */ + implementation(projects.domain.tokens.models) + implementation(projects.domain.wallets.models) + + /** Compose */ + implementation(deps.compose.runtime) + implementation(deps.compose.foundation) + + /** Other */ + implementation(deps.kotlin.immutable.collections) +} \ No newline at end of file diff --git a/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/TxHistoryFeatureToggles.kt b/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/TxHistoryFeatureToggles.kt new file mode 100644 index 0000000000..a0cb28761f --- /dev/null +++ b/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/TxHistoryFeatureToggles.kt @@ -0,0 +1,5 @@ +package com.tangem.features.txhistory + +interface TxHistoryFeatureToggles { + val isFeatureEnabled: Boolean +} \ No newline at end of file diff --git a/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/component/TxHistoryComponent.kt b/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/component/TxHistoryComponent.kt new file mode 100644 index 0000000000..8fa11c9c93 --- /dev/null +++ b/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/component/TxHistoryComponent.kt @@ -0,0 +1,28 @@ +package com.tangem.features.txhistory.component + +import androidx.compose.runtime.Stable +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.features.txhistory.entity.TxHistoryUM +import kotlinx.coroutines.flow.StateFlow + +@Stable +interface TxHistoryComponent { + + val txHistoryState: StateFlow + + fun LazyListScope.txHistoryContent(listState: LazyListState, state: TxHistoryUM) + + fun reload() + + data class Params( + val userWalletId: UserWalletId, + val currency: CryptoCurrency, + val openExplorer: () -> Unit, + ) + + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryContentUpdateEmitter.kt b/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryContentUpdateEmitter.kt new file mode 100644 index 0000000000..8f8f68a500 --- /dev/null +++ b/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryContentUpdateEmitter.kt @@ -0,0 +1,5 @@ +package com.tangem.features.txhistory.entity + +interface TxHistoryContentUpdateEmitter { + suspend fun triggerUpdate() +} \ No newline at end of file diff --git a/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryUM.kt b/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryUM.kt new file mode 100644 index 0000000000..9a9960fc8e --- /dev/null +++ b/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryUM.kt @@ -0,0 +1,93 @@ +package com.tangem.features.txhistory.entity + +import com.tangem.core.ui.components.transactions.state.TransactionState +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +sealed interface TxHistoryUM { + + val isBalanceHidden: Boolean + + data class Loading(override val isBalanceHidden: Boolean, private val onExploreClick: () -> Unit) : TxHistoryUM { + val items = persistentListOf( + TxHistoryItemUM.Title(onExploreClick = onExploreClick), + TxHistoryItemUM.Transaction(TransactionState.Loading("LOADING_TX_HASH_1")), + TxHistoryItemUM.Transaction(TransactionState.Loading("LOADING_TX_HASH_2")), + TxHistoryItemUM.Transaction(TransactionState.Loading("LOADING_TX_HASH_3")), + ) + } + + /** + * Wallet transaction history state with content + */ + data class Content( + override val isBalanceHidden: Boolean, + val items: ImmutableList, + val loadMore: () -> Boolean, + ) : TxHistoryUM + + /** Empty state */ + data class Empty(override val isBalanceHidden: Boolean, val onExploreClick: () -> Unit) : TxHistoryUM + + /** + * Not supported tx history state + * + * @property pendingTransactions pending transactions + * @property onExploreClick lambda be invoke when explore button was clicked + */ + data class NotSupported( + override val isBalanceHidden: Boolean, + val pendingTransactions: ImmutableList, + val onExploreClick: () -> Unit, + ) : TxHistoryUM + + /** + * Error state + * + * @property onReloadClick lambda be invoke when reload button was clicked + */ + data class Error( + override val isBalanceHidden: Boolean, + val onReloadClick: () -> Unit, + val onExploreClick: () -> Unit, + ) : TxHistoryUM + + fun copySealed(isBalanceHidden: Boolean): TxHistoryUM { + return when (this) { + is Content -> copy(isBalanceHidden = isBalanceHidden) + is NotSupported -> copy(isBalanceHidden = isBalanceHidden) + is Empty -> copy(isBalanceHidden = isBalanceHidden) + is Error -> copy(isBalanceHidden = isBalanceHidden) + is Loading -> copy(isBalanceHidden = isBalanceHidden) + } + } + + /** Transactions history item state */ + sealed interface TxHistoryItemUM { + + /** + * Title item + * + * @property onExploreClick lambda be invoke when explore button was clicked + */ + data class Title(val onExploreClick: () -> Unit) : TxHistoryItemUM + + /** + * Group title item + * + * @property title title + * @property itemKey key to use in compose + */ + data class GroupTitle( + val title: String, + val itemKey: String, + ) : TxHistoryItemUM + + /** + * Transaction item + * + * @property state transaction state + */ + data class Transaction(val state: TransactionState) : TxHistoryItemUM + } +} \ No newline at end of file diff --git a/features/txhistory/impl/.gitignore b/features/txhistory/impl/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/features/txhistory/impl/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/features/txhistory/impl/build.gradle.kts b/features/txhistory/impl/build.gradle.kts new file mode 100644 index 0000000000..b0cc54f7ef --- /dev/null +++ b/features/txhistory/impl/build.gradle.kts @@ -0,0 +1,59 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + alias(deps.plugins.kotlin.serialization) + alias(deps.plugins.kotlin.kapt) + alias(deps.plugins.hilt.android) + id("configuration") +} + +android { + namespace = "com.tangem.features.txhistory.impl" +} + +dependencies { + /* Project - API */ + implementation(projects.features.txhistory.api) + + /* Project - Core */ + implementation(projects.core.decompose) + implementation(projects.core.ui) + implementation(projects.common.routing) + implementation(projects.core.configToggles) + implementation(projects.core.analytics) + implementation(projects.core.pagination) + implementation(projects.core.navigation) + + /* Project - Domain */ + implementation(projects.domain.models) + implementation(projects.domain.legacy) + implementation(projects.domain.txhistory) + implementation(projects.domain.txhistory.models) + implementation(projects.domain.wallets) + implementation(projects.domain.wallets.models) + implementation(projects.domain.tokens) + implementation(projects.domain.tokens.models) + implementation(projects.domain.balanceHiding) + implementation(projects.domain.balanceHiding.models) + + /* AndroidX */ + implementation(deps.androidx.activity.compose) + implementation(deps.lifecycle.compose) + + /* Compose */ + implementation(deps.compose.ui) + implementation(deps.compose.ui.tooling) + implementation(deps.compose.foundation) + implementation(deps.compose.material3) + implementation(deps.compose.shimmer) + + /* DI */ + implementation(deps.hilt.android) + kapt(deps.hilt.kapt) + + /* Other */ + implementation(deps.arrow.core) + implementation(deps.kotlin.immutable.collections) + implementation(deps.decompose.ext.compose) + implementation(deps.timber) +} \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/DefaultTxHistoryFeatureToggles.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/DefaultTxHistoryFeatureToggles.kt new file mode 100644 index 0000000000..862db63ebb --- /dev/null +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/DefaultTxHistoryFeatureToggles.kt @@ -0,0 +1,11 @@ +package com.tangem.features.txhistory + +import com.tangem.core.configtoggle.feature.FeatureTogglesManager +import javax.inject.Inject + +internal class DefaultTxHistoryFeatureToggles @Inject constructor( + private val featureTogglesManager: FeatureTogglesManager, +) : TxHistoryFeatureToggles { + override val isFeatureEnabled: Boolean + get() = featureTogglesManager.isFeatureEnabled("TX_HISTORY_REFACTORING_ENABLED") +} \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/component/DefaultTxHistoryComponent.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/component/DefaultTxHistoryComponent.kt new file mode 100644 index 0000000000..e3e25ebc5e --- /dev/null +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/component/DefaultTxHistoryComponent.kt @@ -0,0 +1,38 @@ +package com.tangem.features.txhistory.component + +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.* +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.features.txhistory.entity.TxHistoryUM +import com.tangem.features.txhistory.model.TxHistoryModel +import com.tangem.features.txhistory.ui.txHistoryItems +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.StateFlow + +internal class DefaultTxHistoryComponent @AssistedInject constructor( + @Assisted appComponentContext: AppComponentContext, + @Assisted params: TxHistoryComponent.Params, +) : TxHistoryComponent, AppComponentContext by appComponentContext { + + private val model: TxHistoryModel = getOrCreateModel(params) + + override val txHistoryState: StateFlow + get() = model.uiState + + override fun LazyListScope.txHistoryContent(listState: LazyListState, state: TxHistoryUM) { + txHistoryItems(listState, state) + } + + override fun reload() { + model.reload() + } + + @AssistedFactory + interface Factory : TxHistoryComponent.Factory { + override fun create(context: AppComponentContext, params: TxHistoryComponent.Params): DefaultTxHistoryComponent + } +} \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryItemToTransactionStateConverter.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryItemToTransactionStateConverter.kt new file mode 100644 index 0000000000..7db10fcb7b --- /dev/null +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryItemToTransactionStateConverter.kt @@ -0,0 +1,129 @@ +package com.tangem.features.txhistory.converter + +import com.tangem.core.ui.components.transactions.state.TransactionState +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.format.bigdecimal.crypto +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.utils.toTimeFormat +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.txhistory.models.TxHistoryItem +import com.tangem.features.txhistory.impl.R +import com.tangem.features.txhistory.utils.TxHistoryUiActions +import com.tangem.utils.StringsSigns +import com.tangem.utils.converter.Converter +import com.tangem.utils.extensions.isZero +import com.tangem.utils.toBriefAddressFormat + +internal class TxHistoryItemToTransactionStateConverter( + private val currency: CryptoCurrency, + private val txHistoryUiActions: TxHistoryUiActions, +) : Converter { + override fun convert(value: TxHistoryItem): TransactionState { + return TransactionState.Content( + txHash = value.txHash, + amount = value.getAmount(), + time = value.timestampInMillis.toTimeFormat(), + status = value.status.tiUiStatus(), + direction = value.extractDirection(), + iconRes = value.extractIcon(), + title = value.extractTitle(), + subtitle = value.extractSubtitle(), + timestamp = value.timestampInMillis, + onClick = { txHistoryUiActions.openTxInExplorer(value.txHash) }, + ) + } + + private fun TxHistoryItem.extractIcon(): Int = if (status == TxHistoryItem.TransactionStatus.Failed) { + R.drawable.ic_close_24 + } else { + when (type) { + is TxHistoryItem.TransactionType.Approve -> R.drawable.ic_doc_24 + is TxHistoryItem.TransactionType.Staking.Stake, + is TxHistoryItem.TransactionType.Staking.Vote, + is TxHistoryItem.TransactionType.Staking.Restake, + -> R.drawable.ic_transaction_history_staking_24 + is TxHistoryItem.TransactionType.Staking.ClaimRewards, + -> R.drawable.ic_transaction_history_claim_rewards_24 + is TxHistoryItem.TransactionType.Staking.Unstake, + is TxHistoryItem.TransactionType.Staking.Withdraw, + -> R.drawable.ic_transaction_history_unstaking_24 + is TxHistoryItem.TransactionType.Operation, + is TxHistoryItem.TransactionType.Swap, + is TxHistoryItem.TransactionType.Transfer, + is TxHistoryItem.TransactionType.UnknownOperation, + -> if (isOutgoing) R.drawable.ic_arrow_up_24 else R.drawable.ic_arrow_down_24 + } + } + + private fun TxHistoryItem.extractTitle(): TextReference = when (val type = type) { + is TxHistoryItem.TransactionType.Approve -> resourceReference(R.string.common_approval) + is TxHistoryItem.TransactionType.Operation -> stringReference(type.name) + is TxHistoryItem.TransactionType.Swap -> resourceReference(R.string.common_swap) + is TxHistoryItem.TransactionType.Transfer -> resourceReference(R.string.common_transfer) + is TxHistoryItem.TransactionType.Staking.Stake -> resourceReference(R.string.common_stake) + is TxHistoryItem.TransactionType.Staking.Unstake -> resourceReference(R.string.common_unstake) + is TxHistoryItem.TransactionType.Staking.Vote -> resourceReference(R.string.staking_vote) + is TxHistoryItem.TransactionType.Staking.ClaimRewards -> resourceReference(R.string.common_claim_rewards) + is TxHistoryItem.TransactionType.Staking.Withdraw -> resourceReference(R.string.staking_withdraw) + is TxHistoryItem.TransactionType.Staking.Restake -> resourceReference(R.string.staking_restake) + is TxHistoryItem.TransactionType.UnknownOperation -> resourceReference(R.string.transaction_history_operation) + } + + private fun TxHistoryItem.extractSubtitle(): TextReference = + when (val interactionAddress = interactionAddressType) { + is TxHistoryItem.InteractionAddressType.Contract -> resourceReference( + id = R.string.transaction_history_contract_address, + formatArgs = wrappedList(interactionAddress.address.toBriefAddressFormat()), + ) + is TxHistoryItem.InteractionAddressType.Multiple -> resourceReference( + id = if (isOutgoing) { + R.string.transaction_history_transaction_to_address + } else { + R.string.transaction_history_transaction_from_address + }, + formatArgs = wrappedList(resourceReference(R.string.transaction_history_multiple_addresses)), + ) + is TxHistoryItem.InteractionAddressType.User -> resourceReference( + id = if (isOutgoing) { + R.string.transaction_history_transaction_to_address + } else { + R.string.transaction_history_transaction_from_address + }, + formatArgs = wrappedList(interactionAddress.address.toBriefAddressFormat()), + ) + is TxHistoryItem.InteractionAddressType.Validator -> resourceReference( + id = R.string.transaction_history_transaction_validator, + formatArgs = wrappedList(interactionAddress.address.toBriefAddressFormat()), + ) + null -> { + TextReference.EMPTY + } + } + + private fun TxHistoryItem.extractDirection() = + if (isOutgoing) TransactionState.Content.Direction.OUTGOING else TransactionState.Content.Direction.INCOMING + + private fun TxHistoryItem.getAmount(): String { + if (type is TxHistoryItem.TransactionType.Staking.Vote || + type == TxHistoryItem.TransactionType.Staking.ClaimRewards || + type == TxHistoryItem.TransactionType.Staking.Withdraw + ) { + return "" + } + val prefix = when { + status == TxHistoryItem.TransactionStatus.Failed -> "" + this.amount.isZero() -> "" + else -> if (isOutgoing) StringsSigns.MINUS else StringsSigns.PLUS + } + return prefix + amount.format { crypto(symbol = currency.symbol, decimals = currency.decimals) } + } + + private fun TxHistoryItem.TransactionStatus.tiUiStatus() = when (this) { + TxHistoryItem.TransactionStatus.Confirmed -> TransactionState.Content.Status.Confirmed + TxHistoryItem.TransactionStatus.Failed -> TransactionState.Content.Status.Failed + TxHistoryItem.TransactionStatus.Unconfirmed -> TransactionState.Content.Status.Unconfirmed + } +} \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/di/TxHistoryFeatureModule.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/di/TxHistoryFeatureModule.kt new file mode 100644 index 0000000000..7ea832e433 --- /dev/null +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/di/TxHistoryFeatureModule.kt @@ -0,0 +1,23 @@ +package com.tangem.features.txhistory.di + +import com.tangem.features.txhistory.DefaultTxHistoryFeatureToggles +import com.tangem.features.txhistory.TxHistoryFeatureToggles +import com.tangem.features.txhistory.component.DefaultTxHistoryComponent +import com.tangem.features.txhistory.component.TxHistoryComponent +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal interface TxHistoryFeatureModule { + @Binds + @Singleton + fun provideFeatureToggles(featureToggles: DefaultTxHistoryFeatureToggles): TxHistoryFeatureToggles + + @Binds + @Singleton + fun bindComponentFactory(factory: DefaultTxHistoryComponent.Factory): TxHistoryComponent.Factory +} \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/di/TxHistoryModelModule.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/di/TxHistoryModelModule.kt new file mode 100644 index 0000000000..a968473a55 --- /dev/null +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/di/TxHistoryModelModule.kt @@ -0,0 +1,19 @@ +package com.tangem.features.txhistory.di + +import com.tangem.core.decompose.di.ModelComponent +import com.tangem.core.decompose.model.Model +import com.tangem.features.txhistory.model.TxHistoryModel +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap + +@Module +@InstallIn(ModelComponent::class) +internal interface TxHistoryModelModule { + @Binds + @IntoMap + @ClassKey(TxHistoryModel::class) + fun bindModel(model: TxHistoryModel): Model +} \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/di/TxHistoryUpdaterModule.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/di/TxHistoryUpdaterModule.kt new file mode 100644 index 0000000000..e5b1f73531 --- /dev/null +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/di/TxHistoryUpdaterModule.kt @@ -0,0 +1,23 @@ +package com.tangem.features.txhistory.di + +import com.tangem.features.txhistory.entity.DefaultTxHistoryUpdater +import com.tangem.features.txhistory.entity.TxHistoryContentUpdateEmitter +import com.tangem.features.txhistory.entity.TxHistoryUpdateListener +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal object TxHistoryUpdaterModule { + + @Provides + @Singleton + fun provideTxHistoryContentContentUpdateEmitter(impl: DefaultTxHistoryUpdater): TxHistoryContentUpdateEmitter = impl + + @Provides + @Singleton + fun provideTxHistoryUpdaterListener(impl: DefaultTxHistoryUpdater): TxHistoryUpdateListener = impl +} \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/entity/DefaultTxHistoryUpdater.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/entity/DefaultTxHistoryUpdater.kt new file mode 100644 index 0000000000..fd2bf297da --- /dev/null +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/entity/DefaultTxHistoryUpdater.kt @@ -0,0 +1,18 @@ +package com.tangem.features.txhistory.entity + +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.receiveAsFlow +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +internal class DefaultTxHistoryUpdater @Inject constructor() : TxHistoryUpdateListener, TxHistoryContentUpdateEmitter { + + private val updateChannel = Channel(Channel.BUFFERED) + override val updates: Flow = updateChannel.receiveAsFlow() + + override suspend fun triggerUpdate() { + updateChannel.send(Unit) + } +} \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryUpdateListener.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryUpdateListener.kt new file mode 100644 index 0000000000..32dd1035a7 --- /dev/null +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryUpdateListener.kt @@ -0,0 +1,7 @@ +package com.tangem.features.txhistory.entity + +import kotlinx.coroutines.flow.Flow + +internal interface TxHistoryUpdateListener { + val updates: Flow +} \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryModel.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryModel.kt new file mode 100644 index 0000000000..593f171318 --- /dev/null +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryModel.kt @@ -0,0 +1,202 @@ +package com.tangem.features.txhistory.model + +import androidx.compose.runtime.Stable +import arrow.core.Either +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.navigation.url.UrlOpener +import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase +import com.tangem.domain.common.util.cardTypesResolver +import com.tangem.domain.tokens.error.CurrencyStatusError +import com.tangem.domain.tokens.GetCurrencyStatusUpdatesUseCase +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.domain.txhistory.models.TxHistoryStateError +import com.tangem.domain.txhistory.repository.TxHistoryRepositoryV2 +import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase +import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsCountUseCase +import com.tangem.domain.wallets.models.UserWallet +import com.tangem.domain.wallets.usecase.GetUserWalletUseCase +import com.tangem.features.txhistory.component.TxHistoryComponent +import com.tangem.features.txhistory.converter.TxHistoryItemToTransactionStateConverter +import com.tangem.features.txhistory.entity.TxHistoryUM +import com.tangem.features.txhistory.entity.TxHistoryUpdateListener +import com.tangem.features.txhistory.utils.TxHistoryListManager +import com.tangem.features.txhistory.utils.TxHistoryUiActions +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@Suppress("LongParameterList") +@Stable +@ModelScoped +internal class TxHistoryModel @Inject constructor( + override val dispatchers: CoroutineDispatcherProvider, + private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, + private val txHistoryItemsCountUseCase: GetTxHistoryItemsCountUseCase, + private val getCurrencyStatusUpdatesUseCase: GetCurrencyStatusUpdatesUseCase, + private val getUserWalletUseCase: GetUserWalletUseCase, + private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase, + private val urlOpener: UrlOpener, + private val txHistoryUpdateListener: TxHistoryUpdateListener, + repository: TxHistoryRepositoryV2, + paramsContainer: ParamsContainer, +) : Model(), TxHistoryUiActions { + + private val params: TxHistoryComponent.Params = paramsContainer.require() + private val txHistoryItemConverter = + TxHistoryItemToTransactionStateConverter(currency = params.currency, txHistoryUiActions = this) + private val txHistoryListManager = TxHistoryListManager( + repository = repository, + dispatchers = dispatchers, + userWalletId = params.userWalletId, + currency = params.currency, + txHistoryItemConverter = txHistoryItemConverter, + txHistoryUiActions = this, + ) + private val _uiState: MutableStateFlow = + MutableStateFlow(TxHistoryUM.Loading(isBalanceHidden = true, onExploreClick = ::openExplorer)) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + handleBalanceHiding() + subscribeToUiItemChanges() + loadTxInfo() + subscribeToUpdateListener() + subscribeOnCurrencyStatusUpdates() + } + + private fun subscribeToUiItemChanges() { + txHistoryListManager.uiItems + .onEach { updateState(it) } + .launchIn(modelScope) + } + + private fun subscribeToUpdateListener() { + txHistoryUpdateListener.updates + .onEach { reload() } + .launchIn(modelScope) + } + + private fun loadTxInfo() { + _uiState.update { state -> getLoadingState(state.isBalanceHidden) } + modelScope.launch { + txHistoryItemsCountUseCase.invoke(userWalletId = params.userWalletId, currency = params.currency) + .onLeft(::handleErrorState) + .onRight { txHistoryListManager.startLoading() } + } + } + + fun reload() { + // fast exit + if (uiState.value is TxHistoryUM.NotSupported) return + + _uiState.update { state -> + if (state !is TxHistoryUM.Content) getLoadingState(state.isBalanceHidden) else state + } + modelScope.launch { + txHistoryItemsCountUseCase.invoke(userWalletId = params.userWalletId, currency = params.currency) + .onLeft(::handleErrorState) + .onRight { txHistoryListManager.reload() } + } + } + + private fun handleBalanceHiding() { + getBalanceHidingSettingsUseCase() + .onEach { _uiState.update { state -> state.copySealed(isBalanceHidden = it.isBalanceHidden) } } + .launchIn(modelScope) + } + + private fun loadMoreItems(): Boolean { + modelScope.launch { txHistoryListManager.loadMore(params.userWalletId, params.currency) } + return true + } + + private fun updateState(items: ImmutableList) { + _uiState.update { state -> + if (state is TxHistoryUM.Content) { + state.copy(items = items) + } else { + TxHistoryUM.Content( + items = items, + isBalanceHidden = state.isBalanceHidden, + loadMore = ::loadMoreItems, + ) + } + } + } + + private fun handleErrorState(error: TxHistoryStateError) { + _uiState.update { state -> + when (error) { + is TxHistoryStateError.DataError -> TxHistoryUM.Error( + isBalanceHidden = state.isBalanceHidden, + onReloadClick = ::reload, + onExploreClick = ::openExplorer, + ) + TxHistoryStateError.EmptyTxHistories -> TxHistoryUM.Empty( + isBalanceHidden = state.isBalanceHidden, + onExploreClick = ::openExplorer, + ) + TxHistoryStateError.TxHistoryNotImplemented -> TxHistoryUM.NotSupported( + isBalanceHidden = state.isBalanceHidden, + pendingTransactions = persistentListOf(), + onExploreClick = ::openExplorer, + ) + } + } + } + + private fun getLoadingState(isBalanceHidden: Boolean): TxHistoryUM.Loading { + return TxHistoryUM.Loading(isBalanceHidden = isBalanceHidden, onExploreClick = ::openExplorer) + } + + private fun subscribeOnCurrencyStatusUpdates() { + val userWallet: UserWallet = requireNotNull(getUserWalletUseCase(params.userWalletId).getOrNull()) { + "User wallet not found" + } + getCurrencyStatusUpdatesUseCase( + userWalletId = params.userWalletId, + currencyId = params.currency.id, + isSingleWalletWithTokens = userWallet.scanResponse.cardTypesResolver.isSingleWalletWithToken(), + ) + .distinctUntilChanged() + .onEach(::handlePendingTxsChanges) + .flowOn(dispatchers.main) + .launchIn(modelScope) + } + + private fun handlePendingTxsChanges(maybeCurrencyStatus: Either) { + maybeCurrencyStatus.onRight { status -> + val pendingTxs = status.value.pendingTransactions + .map(txHistoryItemConverter::convert) + .toPersistentList() + _uiState.update { state -> + if (state is TxHistoryUM.NotSupported) { + state.copy(pendingTransactions = pendingTxs) + } else { + state + } + } + } + } + + override fun openExplorer() { + params.openExplorer() + } + + override fun openTxInExplorer(txHash: String) { + getExplorerTransactionUrlUseCase( + txHash = txHash, + networkId = params.currency.network.id, + ).fold( + ifLeft = { Timber.e(it.toString()) }, + ifRight = { urlOpener.openUrl(url = it) }, + ) + } +} \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryContent.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryContent.kt new file mode 100644 index 0000000000..9525d0ede2 --- /dev/null +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryContent.kt @@ -0,0 +1,164 @@ +package com.tangem.features.txhistory.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import com.tangem.core.ui.components.list.InfiniteListHandler +import com.tangem.core.ui.components.transactions.PendingTxsBlock +import com.tangem.core.ui.components.transactions.Transaction +import com.tangem.core.ui.components.transactions.TxHistoryTitle +import com.tangem.core.ui.components.transactions.empty.EmptyTransactionBlock +import com.tangem.core.ui.components.transactions.empty.EmptyTransactionsBlockState +import com.tangem.core.ui.components.transactions.state.TransactionState +import com.tangem.core.ui.decorations.roundedShapeItemDecoration +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.txhistory.entity.TxHistoryUM + +private const val LOAD_ITEMS_BUFFER = 20 + +internal fun LazyListScope.txHistoryItems(listState: LazyListState, state: TxHistoryUM) { + when (state) { + is TxHistoryUM.Content -> contentItems(listState, state) + is TxHistoryUM.Empty -> nonContentItem(state = EmptyTransactionsBlockState.Empty(state.onExploreClick)) + is TxHistoryUM.Error -> nonContentItem( + state = EmptyTransactionsBlockState.FailedToLoad( + onReload = state.onReloadClick, + onExplore = state.onExploreClick, + ), + ) + is TxHistoryUM.Loading -> loadingItems(state) + is TxHistoryUM.NotSupported -> { + if (state.pendingTransactions.isNotEmpty()) { + item(key = "PendingTxsBlock", contentType = "PendingTxsBlock") { + PendingTxsBlock(pendingTxs = state.pendingTransactions, isBalanceHidden = state.isBalanceHidden) + } + } + + nonContentItem( + state = EmptyTransactionsBlockState.NotImplemented(onExplore = state.onExploreClick), + ) + } + } +} + +private fun LazyListScope.nonContentItem(state: EmptyTransactionsBlockState, modifier: Modifier = Modifier) { + item(key = state::class.java, contentType = state::class.java) { + EmptyTransactionBlock( + state = state, + modifier = modifier + .animateItem(fadeInSpec = null, fadeOutSpec = null) + .padding(horizontal = TangemTheme.dimens.spacing16, vertical = TangemTheme.dimens.spacing12) + .fillMaxWidth(), + ) + } +} + +private fun LazyListScope.loadingItems(state: TxHistoryUM.Loading) { + itemsIndexed( + items = state.items, + key = { _, item -> + when (item) { + is TxHistoryUM.TxHistoryItemUM.GroupTitle -> item.itemKey + is TxHistoryUM.TxHistoryItemUM.Title -> item.onExploreClick.hashCode() + is TxHistoryUM.TxHistoryItemUM.Transaction -> + item.state.txHash + (item.state as? TransactionState.Content)?.hashCode() + } + }, + contentType = { _, item -> item::class.java }, + itemContent = { index, item -> + TxHistoryListItem( + state = item, + isBalanceHidden = true, + modifier = Modifier.roundedShapeItemDecoration( + currentIndex = index, + lastIndex = state.items.lastIndex, + ), + ) + }, + ) +} + +private fun LazyListScope.contentItems(listState: LazyListState, state: TxHistoryUM.Content) { + itemsIndexed( + items = state.items, + key = { _, item -> + when (item) { + is TxHistoryUM.TxHistoryItemUM.GroupTitle -> item.itemKey + is TxHistoryUM.TxHistoryItemUM.Title -> item.onExploreClick.hashCode() + is TxHistoryUM.TxHistoryItemUM.Transaction -> + item.state.txHash + (item.state as? TransactionState.Content)?.hashCode() + } + }, + contentType = { _, item -> item::class.java }, + itemContent = { index, item -> + TxHistoryListItem( + state = item, + isBalanceHidden = state.isBalanceHidden, + modifier = Modifier.roundedShapeItemDecoration( + currentIndex = index, + lastIndex = state.items.lastIndex, + ), + ) + }, + ) + item { + InfiniteListHandler( + listState = listState, + buffer = LOAD_ITEMS_BUFFER, + onLoadMore = state.loadMore, + ) + } +} + +@Composable +internal fun TxHistoryListItem( + state: TxHistoryUM.TxHistoryItemUM, + isBalanceHidden: Boolean, + modifier: Modifier = Modifier, +) { + when (state) { + is TxHistoryUM.TxHistoryItemUM.GroupTitle -> { + TxHistoryGroupTitle(config = state, modifier = modifier) + } + is TxHistoryUM.TxHistoryItemUM.Title -> { + TxHistoryTitle(onExploreClick = state.onExploreClick, modifier = modifier) + } + is TxHistoryUM.TxHistoryItemUM.Transaction -> { + Transaction( + state = state.state, + isBalanceHidden = isBalanceHidden, + modifier = modifier, + ) + } + } +} + +@Composable +private fun TxHistoryGroupTitle(config: TxHistoryUM.TxHistoryItemUM.GroupTitle, modifier: Modifier = Modifier) { + Box( + modifier = modifier + .background(TangemTheme.colors.background.primary) + .padding( + vertical = TangemTheme.dimens.spacing8, + horizontal = TangemTheme.dimens.spacing12, + ) + .fillMaxWidth() + .heightIn(min = TangemTheme.dimens.size24), + contentAlignment = Alignment.CenterStart, + ) { + Text( + text = config.title, + color = TangemTheme.colors.text.tertiary, + style = TangemTheme.typography.body2, + ) + } +} \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryListManager.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryListManager.kt new file mode 100644 index 0000000000..91894dd1d1 --- /dev/null +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryListManager.kt @@ -0,0 +1,99 @@ +package com.tangem.features.txhistory.utils + +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.txhistory.model.TxHistoryListBatchingContext +import com.tangem.domain.txhistory.model.TxHistoryListConfig +import com.tangem.domain.txhistory.models.PaginationWrapper +import com.tangem.domain.txhistory.models.TxHistoryItem +import com.tangem.domain.txhistory.repository.TxHistoryRepositoryV2 +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.features.txhistory.converter.TxHistoryItemToTransactionStateConverter +import com.tangem.features.txhistory.entity.TxHistoryUM +import com.tangem.pagination.BatchAction +import com.tangem.pagination.BatchListState +import com.tangem.pagination.PaginationStatus +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.coroutines.JobHolder +import com.tangem.utils.coroutines.saveIn +import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.* + +private typealias TxHistoryBatchAction = BatchAction + +internal class TxHistoryListManager( + private val repository: TxHistoryRepositoryV2, + private val dispatchers: CoroutineDispatcherProvider, + private val userWalletId: UserWalletId, + private val currency: CryptoCurrency, + txHistoryItemConverter: TxHistoryItemToTransactionStateConverter, + txHistoryUiActions: TxHistoryUiActions, +) { + + private val jobHolder = JobHolder() + private val actionsFlow: MutableSharedFlow = MutableSharedFlow( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + private val state: MutableStateFlow = MutableStateFlow(TxHistoryListState()) + private val uiManager = TxHistoryUiManager( + state = state, + txHistoryItemConverter = txHistoryItemConverter, + txHistoryUiActions = txHistoryUiActions, + ) + + val uiItems: Flow> = uiManager.items + + suspend fun startLoading() = coroutineScope { + val batchFlow = repository.getTxHistoryBatchFlow( + context = TxHistoryListBatchingContext( + actionsFlow = actionsFlow, + coroutineScope = this, + ), + batchSize = 50, + ) + + batchFlow.state + .onEach { state -> updateState(state) } + .flowOn(dispatchers.default) + .launchIn(scope = this) + .saveIn(jobHolder) + + actionsFlow.emit( + BatchAction.Reload( + requestParams = TxHistoryListConfig(userWalletId, currency, refresh = false), + ), + ) + } + + suspend fun reload() { + actionsFlow.emit( + BatchAction.Reload( + requestParams = TxHistoryListConfig(userWalletId, currency, refresh = true), + ), + ) + } + + suspend fun loadMore(userWalletId: UserWalletId, currency: CryptoCurrency) { + actionsFlow.emit( + BatchAction.LoadMore( + requestParams = TxHistoryListConfig(userWalletId, currency, refresh = false), + ), + ) + } + + private fun updateState(batchListState: BatchListState>) { + state.update { state -> + val clearUiBatches = + state.status is PaginationStatus.InitialLoading && batchListState.status is PaginationStatus.Paginating + state.copy( + status = batchListState.status, + uiBatches = uiManager.createOrUpdateUiBatches( + newCurrencyBatches = batchListState.data, + clearUiBatches = clearUiBatches, + ), + ) + } + } +} \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryListState.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryListState.kt new file mode 100644 index 0000000000..8e553fe6ac --- /dev/null +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryListState.kt @@ -0,0 +1,10 @@ +package com.tangem.features.txhistory.utils + +import com.tangem.features.txhistory.entity.TxHistoryUM +import com.tangem.pagination.Batch +import com.tangem.pagination.PaginationStatus + +data class TxHistoryListState( + val status: PaginationStatus<*> = PaginationStatus.None, + val uiBatches: List>> = listOf(), +) \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryUiActions.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryUiActions.kt new file mode 100644 index 0000000000..57a50a9014 --- /dev/null +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryUiActions.kt @@ -0,0 +1,7 @@ +package com.tangem.features.txhistory.utils + +internal interface TxHistoryUiActions { + + fun openExplorer() + fun openTxInExplorer(txHash: String) +} \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryUiManager.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryUiManager.kt new file mode 100644 index 0000000000..2f4aba1080 --- /dev/null +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryUiManager.kt @@ -0,0 +1,113 @@ +package com.tangem.features.txhistory.utils + +import com.tangem.core.ui.utils.toDateFormatWithTodayYesterday +import com.tangem.domain.txhistory.models.PaginationWrapper +import com.tangem.domain.txhistory.models.TxHistoryItem +import com.tangem.features.txhistory.converter.TxHistoryItemToTransactionStateConverter +import com.tangem.features.txhistory.entity.TxHistoryUM +import com.tangem.pagination.Batch +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.mapLatest +import java.util.UUID + +internal class TxHistoryUiManager( + private val state: MutableStateFlow, + private val txHistoryItemConverter: TxHistoryItemToTransactionStateConverter, + private val txHistoryUiActions: TxHistoryUiActions, +) { + + @OptIn(ExperimentalCoroutinesApi::class) + val items: Flow> = state + .mapLatest { state -> + state.uiBatches.asSequence() + .flatMap { it.data } + .toImmutableList() + } + .distinctUntilChanged() + + fun createOrUpdateUiBatches( + newCurrencyBatches: List>>, + clearUiBatches: Boolean, + ): List>> { + val currentUiBatches = state.value.uiBatches + val batches = if (clearUiBatches) mutableListOf() else currentUiBatches.toMutableList() + + for ((key, data) in newCurrencyBatches) { + // Find if batch with same key exists + val existingBatchIndex = batches.indexOfFirst { it.key == key } + val shouldUpdateExisting = existingBatchIndex != -1 && + currentUiBatches[existingBatchIndex].data.transactionItemsSizeNotEqual(data.items) + + // Case 1: Update existing batch if sizes differ + if (shouldUpdateExisting) { + val items = generateUiItems(key, data) + batches[existingBatchIndex] = Batch(key = key, data = items) + continue + } + + // Case 2: Skip if batch exists and has same size + if (existingBatchIndex != -1) { + continue + } + + // Case 3: Create new batch + val items = generateUiItems(key, data) + batches.add(Batch(key = key, data = items)) + } + + return batches + } + + private fun generateUiItems(key: Int, data: PaginationWrapper): List { + val items = mutableListOf() + + // Add title for the first batch + if (key == 0) { + items.add(TxHistoryUM.TxHistoryItemUM.Title(onExploreClick = txHistoryUiActions::openExplorer)) + } + + // Process batch items only if there are any + if (data.items.isNotEmpty()) { + // Add first item with its group title + val firstItem = data.items.first() + val firstDate = firstItem.timestampInMillis.toDateFormatWithTodayYesterday() + + items.add( + TxHistoryUM.TxHistoryItemUM.GroupTitle( + title = firstDate, + itemKey = UUID.randomUUID().toString(), + ), + ) + items.add(TxHistoryUM.TxHistoryItemUM.Transaction(txHistoryItemConverter.convert(firstItem))) + + // Process remaining items with date separators when needed + data.items.zipWithNext { current, next -> + val currentDate = current.timestampInMillis.toDateFormatWithTodayYesterday() + val nextDate = next.timestampInMillis.toDateFormatWithTodayYesterday() + + if (currentDate != nextDate) { + items.add( + TxHistoryUM.TxHistoryItemUM.GroupTitle( + title = nextDate, + itemKey = UUID.randomUUID().toString(), + ), + ) + } + items.add(TxHistoryUM.TxHistoryItemUM.Transaction(txHistoryItemConverter.convert(next))) + } + } + + return items + } + + private fun List.transactionItemsSizeNotEqual( + txHistoryItems: List, + ): Boolean { + return this.filterIsInstance().size != txHistoryItems.size + } +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index ddb8968ba6..2e31c05b4e 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -224,6 +224,9 @@ include(":features:onramp:impl") include(":features:stories:api") include(":features:stories:impl") + +include(":features:txhistory:api") +include(":features:txhistory:impl") // endregion Feature modules // region Domain modules