Updated on 2026-08-14

This commit is contained in:
Tangem 2025-03-05 13:16:45 +05:00
parent 1437c14019
commit 3f671e3e0f
46 changed files with 1454 additions and 43 deletions

View file

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

View file

@ -54,5 +54,9 @@
{
"name": "STAKING_CARDANO_ENABLED",
"version": "undefined"
},
{
"name": "TX_HISTORY_REFACTORING_ENABLED",
"version": "undefined"
}
]

View file

@ -289,7 +289,7 @@ private class DefaultBatchListSource<TKey, TData, TRequestParams : Any, TUpdate>
batchFetcher.fetchNext(action.requestParams, lastResult)
}.getOrElse { BatchFetchResult.Error(it) }
lastRequestResult.value = lastResult
lastRequestResult.value = res
state.update { currentState ->
when (res) {

View file

@ -445,5 +445,6 @@ private class TransactionItemStateProvider : CollectionPreviewParameterProvider<
onClick = {},
),
TransactionState.Loading(txHash = UUID.randomUUID().toString()),
TransactionState.Locked(txHash = UUID.randomUUID().toString()),
),
)

View file

@ -104,7 +104,7 @@ private fun LazyListScope.contentItems(
}
@Composable
private fun PendingTxsBlock(pendingTxs: ImmutableList<TransactionState>, isBalanceHidden: Boolean) {
fun PendingTxsBlock(pendingTxs: ImmutableList<TransactionState>, isBalanceHidden: Boolean) {
Column(
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing12)

View file

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

View file

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

View file

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

View file

@ -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<TxHistoryListConfig, PaginationWrapper<TxHistoryItem>> =
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<TxHistoryListConfig>,
batchSize: Int,
): PaginationWrapper<TxHistoryItem> {
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<TxHistoryListConfig>, 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<TxHistoryItem> {
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<TxHistoryItem>.addRecentTransactions(
config: TxHistoryListConfig,
): PaginationWrapper<TxHistoryItem> {
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<TxHistoryItem>.filterUnconfirmedTransaction(): List<TxHistoryItem> {
return filter { it.status == TxHistoryItem.TransactionStatus.Unconfirmed }
}
private fun List<TxHistoryItem>.filterIfTxAlreadyAdded(apiItems: List<TxHistoryItem>): List<TxHistoryItem> {
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"
}
}

View file

@ -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<TRequestParams : Any, TData : PaginationWrapper<TxHistoryItem>>(
private val subFetcher: SubFetcher<TRequestParams, TData>,
) : BatchFetcher<TRequestParams, TData> {
data class Request<TRequestParams>(val page: Page, val params: TRequestParams)
fun interface SubFetcher<TRequestParams : Any, TData> {
suspend fun fetch(
request: Request<TRequestParams>,
lastResult: BatchFetchResult<TData>?,
): BatchFetchResult<TData>
}
private val lastRequest = MutableStateFlow<Request<TRequestParams>?>(null)
override suspend fun fetchFirst(requestParams: TRequestParams): BatchFetchResult<TData> {
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<TData>,
): BatchFetchResult<TData> {
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
}
}

View file

@ -20,4 +20,6 @@ dependencies {
/** Android - Other */
implementation(deps.androidx.paging.runtime)
api(projects.core.pagination)
}

View file

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

View file

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

View file

@ -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<Int, TxHistoryListConfig, Nothing>
typealias TxHistoryListBatchFlow = BatchFlow<Int, PaginationWrapper<TxHistoryItem>, Nothing>

View file

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

View file

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

View file

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

View file

@ -75,6 +75,7 @@ dependencies {
/** Feature modules */
implementation(projects.features.staking.api)
implementation(projects.features.txhistory.api)
/** DI */
implementation(deps.hilt.android)

View file

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

View file

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

View file

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

View file

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

View file

@ -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<TxHistoryState.TxHistoryItemState>?,
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 <T> State<T>?.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,
)
}
}

1
features/txhistory/api/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

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

View file

@ -0,0 +1,5 @@
package com.tangem.features.txhistory
interface TxHistoryFeatureToggles {
val isFeatureEnabled: Boolean
}

View file

@ -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<TxHistoryUM>
fun LazyListScope.txHistoryContent(listState: LazyListState, state: TxHistoryUM)
fun reload()
data class Params(
val userWalletId: UserWalletId,
val currency: CryptoCurrency,
val openExplorer: () -> Unit,
)
interface Factory : ComponentFactory<Params, TxHistoryComponent>
}

View file

@ -0,0 +1,5 @@
package com.tangem.features.txhistory.entity
interface TxHistoryContentUpdateEmitter {
suspend fun triggerUpdate()
}

View file

@ -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<TxHistoryItemUM>,
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<TransactionState>,
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
}
}

1
features/txhistory/impl/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

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

View file

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

View file

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

View file

@ -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<TxHistoryItem, TransactionState> {
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
}
}

View file

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

View file

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

View file

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

View file

@ -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<Unit>(Channel.BUFFERED)
override val updates: Flow<Unit> = updateChannel.receiveAsFlow()
override suspend fun triggerUpdate() {
updateChannel.send(Unit)
}
}

View file

@ -0,0 +1,7 @@
package com.tangem.features.txhistory.entity
import kotlinx.coroutines.flow.Flow
internal interface TxHistoryUpdateListener {
val updates: Flow<Unit>
}

View file

@ -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<TxHistoryUM> =
MutableStateFlow(TxHistoryUM.Loading(isBalanceHidden = true, onExploreClick = ::openExplorer))
val uiState: StateFlow<TxHistoryUM> = _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<TxHistoryUM.TxHistoryItemUM>) {
_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<CurrencyStatusError, CryptoCurrencyStatus>) {
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) },
)
}
}

View file

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

View file

@ -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<Int, TxHistoryListConfig, Nothing>
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<TxHistoryBatchAction> = MutableSharedFlow(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
private val state: MutableStateFlow<TxHistoryListState> = MutableStateFlow(TxHistoryListState())
private val uiManager = TxHistoryUiManager(
state = state,
txHistoryItemConverter = txHistoryItemConverter,
txHistoryUiActions = txHistoryUiActions,
)
val uiItems: Flow<ImmutableList<TxHistoryUM.TxHistoryItemUM>> = 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<Int, PaginationWrapper<TxHistoryItem>>) {
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,
),
)
}
}
}

View file

@ -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<Batch<Int, List<TxHistoryUM.TxHistoryItemUM>>> = listOf(),
)

View file

@ -0,0 +1,7 @@
package com.tangem.features.txhistory.utils
internal interface TxHistoryUiActions {
fun openExplorer()
fun openTxInExplorer(txHash: String)
}

View file

@ -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<TxHistoryListState>,
private val txHistoryItemConverter: TxHistoryItemToTransactionStateConverter,
private val txHistoryUiActions: TxHistoryUiActions,
) {
@OptIn(ExperimentalCoroutinesApi::class)
val items: Flow<ImmutableList<TxHistoryUM.TxHistoryItemUM>> = state
.mapLatest { state ->
state.uiBatches.asSequence()
.flatMap { it.data }
.toImmutableList()
}
.distinctUntilChanged()
fun createOrUpdateUiBatches(
newCurrencyBatches: List<Batch<Int, PaginationWrapper<TxHistoryItem>>>,
clearUiBatches: Boolean,
): List<Batch<Int, List<TxHistoryUM.TxHistoryItemUM>>> {
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<TxHistoryItem>): List<TxHistoryUM.TxHistoryItemUM> {
val items = mutableListOf<TxHistoryUM.TxHistoryItemUM>()
// 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<TxHistoryUM.TxHistoryItemUM>.transactionItemsSizeNotEqual(
txHistoryItems: List<TxHistoryItem>,
): Boolean {
return this.filterIsInstance<TxHistoryUM.TxHistoryItemUM.Transaction>().size != txHistoryItems.size
}
}

View file

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