Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-07 17:09:10 +04:00
parent 9efe573323
commit 9af7bad7b8
22 changed files with 1680 additions and 238 deletions

View file

@ -32,5 +32,7 @@ internal class TxHistoryInfoToTransactionItemUMConverter(
is TransactionItemUM.Pill -> um.copy(onClick = { txHistoryUiActions.onTransactionClick(value) })
else -> um
}
// todo txHistory: render standalone TangemPay on-chain rows when TangemPay is wired into the history
is OnChainTx.TangemPay -> TODO("TangemPay on-chain row rendering is not implemented yet")
}
}

View file

@ -51,6 +51,8 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverter(
override fun convert(value: TxHistoryInfo): TxHistoryDetailsUM = when (value) {
is OnChainTx.BSDK -> onChainConverter.convert(value.txInfo)
// todo txHistory: build the details card for standalone TangemPay rows when TangemPay is wired in
is OnChainTx.TangemPay -> TODO("TangemPay on-chain details rendering is not implemented yet")
is ExpressTx -> expressConverter.convert(value)
}
}

View file

@ -15,6 +15,8 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.txhistory.TxHistoryFeatureToggles
import com.tangem.domain.txhistory.fetcher.AppTxHistoryFetcher
import com.tangem.domain.txhistory.fetcher.TxHistoryFetchTrigger
import com.tangem.domain.txhistory.list.HistoryTxListManager
import com.tangem.domain.txhistory.list.txHistoryInfoFlow
import com.tangem.domain.txhistory.model.TxHistoryInfo
import com.tangem.domain.txhistory.model.explorerHash
import com.tangem.domain.txhistory.models.TxHistoryStateError
@ -30,9 +32,7 @@ import com.tangem.features.txhistory.entity.TxHistoryItemsUM
import com.tangem.features.txhistory.entity.TxHistoryUpdateListener
import com.tangem.features.txhistory.state.TxHistoryItemsSnapshot
import com.tangem.features.txhistory.state.TxHistoryStateController
import com.tangem.features.txhistory.utils.HistoryTxListManager
import com.tangem.features.txhistory.utils.TxHistoryListManager
import com.tangem.features.txhistory.utils.TxHistoryUiActions
import com.tangem.features.txhistory.utils.*
import com.tangem.pagination.PaginationStatus
import com.tangem.utils.annotations.RemoveWithToggle
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -99,6 +99,7 @@ internal class TxHistoryModel @Inject constructor(
historyTxListManagerFactory.create(
userWalletId = params.userWalletId,
currency = params.currency,
modelScope = modelScope,
)
} else {
null
@ -133,23 +134,34 @@ internal class TxHistoryModel @Inject constructor(
if (historyTxListManager != null) {
combine(
flow = historyTxListManager.items,
flow = historyTxListManager.state,
flow2 = lookupDataFlow,
transform = { merged, lookup -> merged to lookup },
transform = { state, lookup -> state to lookup },
)
.onEach { (merged, lookup) ->
stateController.setContent(
snapshot = TxHistoryItemsSnapshot.Items(buildUiItems(merged, lookup)),
loadMore = ::loadMoreItems,
onExploreClick = ::openExplorer,
)
}
.onEach { (state, lookup) -> applyHistoryState(state, lookup) }
.flowOn(dispatchers.default)
.launchIn(modelScope)
}
}
historyTxListManager.paginationStatus
.onEach { paginationStatus -> handlePaginationStatus(paginationStatus) }
.launchIn(modelScope)
private fun applyHistoryState(state: HistoryTxListManager.HistoryState, lookup: TxHistoryLookupContext) {
when (state) {
HistoryTxListManager.HistoryState.Loading ->
stateController.setLoadingIfNotContent(onExploreClick = ::openExplorer)
HistoryTxListManager.HistoryState.Unavailable ->
stateController.setNotSupported(onExploreClick = ::openExplorer)
HistoryTxListManager.HistoryState.Empty ->
stateController.setEmpty(onExploreClick = ::openExplorer)
HistoryTxListManager.HistoryState.Error ->
stateController.setError(onReloadClick = ::reload, onExploreClick = ::openExplorer)
is HistoryTxListManager.HistoryState.Content -> {
stateController.setContent(
snapshot = TxHistoryItemsSnapshot.Items(buildUiItems(state.items, lookup)),
loadMore = ::loadMoreItems,
onExploreClick = ::openExplorer,
)
stateController.updateLoadingMore(isLoadingMore = state.isLoadingMore)
}
}
}
@ -192,19 +204,17 @@ internal class TxHistoryModel @Inject constructor(
private fun initListManager() {
modelScope.launch {
txHistoryListManager?.init()
historyTxListManager?.init()
}
}
private fun loadTxInfo() {
stateController.setLoadingIfNotContent(onExploreClick = ::openExplorer)
modelScope.launch {
txHistoryItemsCountUseCase.invoke(userWalletId = params.userWalletId, currency = params.currency)
.onLeft(::handleErrorState)
.onRight {
txHistoryListManager?.startLoading()
historyTxListManager?.startLoading()
}
txHistoryListManager?.let { legacy ->
txHistoryItemsCountUseCase.invoke(userWalletId = params.userWalletId, currency = params.currency)
.onLeft(::handleErrorState)
.onRight { legacy.startLoading() }
}
}
if (txHistoryFeatureToggle.isNewTxHistoryEnabled) {
val trigger = TxHistoryFetchTrigger.TokenDetailsOpen(
@ -219,13 +229,13 @@ internal class TxHistoryModel @Inject constructor(
if (stateController.isNotSupported) return
stateController.setLoadingIfNotContent(onExploreClick = ::openExplorer)
historyTxListManager?.reload()
modelScope.launch {
txHistoryItemsCountUseCase.invoke(userWalletId = params.userWalletId, currency = params.currency)
.onLeft(::handleErrorState)
.onRight {
txHistoryListManager?.reload()
historyTxListManager?.reload()
}
txHistoryListManager?.let { legacy ->
txHistoryItemsCountUseCase.invoke(userWalletId = params.userWalletId, currency = params.currency)
.onLeft(::handleErrorState)
.onRight { legacy.reload() }
}
if (txHistoryFeatureToggle.isNewTxHistoryEnabled) {
val trigger = TxHistoryFetchTrigger.TokenDetailsPTR(
walletId = params.userWalletId,
@ -245,9 +255,9 @@ internal class TxHistoryModel @Inject constructor(
}
private fun loadMoreItems(): Boolean {
historyTxListManager?.loadMore()
modelScope.launch {
txHistoryListManager?.loadMore(params.userWalletId, params.currency)
historyTxListManager?.loadMore(params.userWalletId, params.currency)
}
return true
}

View file

@ -1,153 +0,0 @@
package com.tangem.features.txhistory.utils
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.TxInfo
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.txhistory.model.*
import com.tangem.domain.txhistory.models.PaginationWrapper
import com.tangem.domain.txhistory.repository.TxHistoryRepositoryV2
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 dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.*
private typealias HistoryTxBatchAction = BatchAction<Int, TxHistoryListConfig, Nothing>
/**
* Redesign-only transaction-history pipeline that merges the on-chain pagination backbone with the
* express (swap/onramp) overlay. Unlike [TxHistoryListManager] there is no legacy branch.
*
* The express overlay is asset-scoped and time-windowed to the oldest loaded on-chain timestamp
* (re-subscribed via [flatMapLatest] as more pages load) and re-emits live as the express DB updates,
* so status changes render without depending on the transaction count.
*/
@Suppress("LongParameterList")
internal class HistoryTxListManager @AssistedInject constructor(
private val repository: TxHistoryRepositoryV2,
private val dispatchers: CoroutineDispatcherProvider,
@Assisted private val userWalletId: UserWalletId,
@Assisted private val currency: CryptoCurrency,
) {
private val jobHolder = JobHolder()
private val actionsFlow: MutableSharedFlow<HistoryTxBatchAction> = MutableSharedFlow(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
private val state: MutableStateFlow<State> = MutableStateFlow(State())
val items: Flow<List<TxHistoryInfo>> = state
.filter { it.hasContent }
.map { it.items }
.distinctUntilChanged()
val paginationStatus: Flow<PaginationStatus<*>> = state.map { it.status }.distinctUntilChanged()
/**
* Reactive stream of a single row tracked by its [TxHistoryInfo.txId], for the in-app details sheet.
*
* Seeded with the tapped [item] so the sheet always has an immediate snapshot, then re-emits the matching row from
* the live merged list as its status changes. The seed also covers rows not present in [items] yet (e.g. a pending
* tx surfaced from the currency status), which would otherwise never resolve.
*/
fun txHistoryInfoFlow(item: TxHistoryInfo): Flow<TxHistoryInfo> = items
.mapNotNull { list -> list.firstOrNull { it.txId == item.txId } }
.onStart { emit(item) }
.distinctUntilChanged()
@OptIn(ExperimentalCoroutinesApi::class)
suspend fun init() {
coroutineScope {
val batchFlow = repository.getTxHistoryBatchFlow(
context = TxHistoryListBatchingContext(actionsFlow = actionsFlow, coroutineScope = this),
batchSize = BATCH_SIZE,
)
val sharedBatchState = batchFlow.state.shareIn(scope = this, started = SharingStarted.Eagerly, replay = 1)
val expressFlow = sharedBatchState
.map(::oldestLoadedTimestamp)
.distinctUntilChanged()
.flatMapLatest { fromCreatedAtMillis ->
repository.getExpressHistory(userWalletId, currency, fromCreatedAtMillis)
}
// Let the merge run on the first on-chain emission before the express query resolves.
.onStart { emit(emptyList()) }
combine(sharedBatchState, expressFlow) { batchState, express ->
buildState(batchState, express)
}
.flowOn(dispatchers.default)
.onEach { state.value = it }
.launchIn(scope = this)
.saveIn(jobHolder)
}
}
suspend fun startLoading() {
actionsFlow.emit(
BatchAction.Reload(requestParams = TxHistoryListConfig(userWalletId, currency, shouldRefresh = false)),
)
}
suspend fun reload() {
actionsFlow.emit(
BatchAction.Reload(requestParams = TxHistoryListConfig(userWalletId, currency, shouldRefresh = true)),
)
}
suspend fun loadMore(userWalletId: UserWalletId, currency: CryptoCurrency) {
actionsFlow.emit(
BatchAction.LoadMore(requestParams = TxHistoryListConfig(userWalletId, currency, shouldRefresh = false)),
)
}
private fun buildState(
batchState: BatchListState<Int, PaginationWrapper<TxInfo>>,
express: List<ExpressTx>,
): State {
val onChain = batchState.data.asSequence()
.flatMap { it.data.items.asSequence() }
.distinctBy(TxInfo::identityKey)
.toList()
val merged = mergeTxHistoryInfos(onChain = onChain, express = express)
return State(status = batchState.status, items = merged)
}
private fun oldestLoadedTimestamp(batchState: BatchListState<Int, PaginationWrapper<TxInfo>>): Long =
batchState.data.asSequence()
.flatMap { it.data.items.asSequence() }
.minOfOrNull { it.timestampInMillis }
?: NO_LOWER_BOUND
private data class State(
val status: PaginationStatus<*> = PaginationStatus.None,
val items: List<TxHistoryInfo> = emptyList(),
) {
val hasContent: Boolean
get() = status !is PaginationStatus.None &&
status !is PaginationStatus.InitialLoading &&
status !is PaginationStatus.InitialLoadingError
}
private companion object {
const val BATCH_SIZE = 50
const val NO_LOWER_BOUND = 0L
}
@AssistedFactory
interface Factory {
fun create(userWalletId: UserWalletId, currency: CryptoCurrency): HistoryTxListManager
}
}

View file

@ -1,51 +0,0 @@
package com.tangem.features.txhistory.utils
import com.tangem.domain.models.network.TxInfo
import com.tangem.domain.txhistory.model.ExpressTx
import com.tangem.domain.txhistory.model.OnChainTx
import com.tangem.domain.txhistory.model.TxHistoryInfo
/**
* Merges the on-chain pagination backbone with the express (swap/onramp) overlay into a single
* timestamp-DESC timeline.
*
* Per express op (matched to on-chain by [ExpressTx.matchHash]):
* - matched enrich: emit the express row carrying its on-chain leg; the on-chain tx(es)
* of that hash are collapsed into this row (not emitted standalone).
* - unmatched standalone row (status shown, no on-chain leg). Both in-progress and terminal
* (finished/failed) express ops are shown so the user always sees their deals.
*
* On-chain transactions that no express op claimed pass through as [OnChainTx].
* [onChain] is expected to be already de-duplicated (by `identityKey`) by the caller.
*/
internal fun mergeTxHistoryInfos(onChain: List<TxInfo>, express: List<ExpressTx>): List<TxHistoryInfo> {
val onChainByHash = onChain.associateBy { it.txHash }
val matchedHashes = mutableSetOf<String>()
val result = mutableListOf<TxHistoryInfo>()
express.forEach { op ->
val matched = op.matchHash?.let(onChainByHash::get)
if (matched != null) {
result += op.withMatchedTxInfo(matched)
matchedHashes += matched.txHash
} else {
result += op
}
}
onChain.forEach { tx ->
if (tx.txHash !in matchedHashes) {
result += OnChainTx.BSDK(tx)
}
}
return result.sortedByDescending(TxHistoryInfo::timestampMillis)
}
private fun ExpressTx.withMatchedTxInfo(txInfo: TxInfo): ExpressTx {
val matched = OnChainTx.BSDK(txInfo)
return when (this) {
is ExpressTx.Swap -> copy(txInfo = matched)
is ExpressTx.Onramp -> copy(txInfo = matched)
}
}

View file

@ -1,131 +0,0 @@
package com.tangem.features.txhistory.utils
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.express.models.ExchangeTransaction
import com.tangem.domain.express.models.ExpressAsset.ID as ExpressAssetId
import com.tangem.domain.express.models.ExpressExchangeStatus
import com.tangem.domain.express.models.ExpressTransactionAsset
import com.tangem.domain.models.network.TxInfo
import com.tangem.domain.txhistory.model.ExpressTx
import com.tangem.domain.txhistory.model.OnChainTx
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.math.BigDecimal
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class TxHistoryInfoMergerTest {
@Test
fun `GIVEN express op matched to on-chain WHEN merge THEN enriched single row and on-chain not duplicated`() {
// Arrange
val onChain = listOf(createTxInfo(txHash = "h1", timestamp = 100))
val express = listOf(createSwap(matchHash = "h1", status = ExpressExchangeStatus.Waiting))
// Act
val result = mergeTxHistoryInfos(onChain, express)
// Assert
assertThat(result).hasSize(1)
val row = result.single()
assertThat(row).isInstanceOf(ExpressTx.Swap::class.java)
assertThat((row as ExpressTx).txInfo).isInstanceOf(OnChainTx.BSDK::class.java)
}
@Test
fun `GIVEN unmatched active express op WHEN merge THEN standalone live row kept`() {
// Arrange
val express = listOf(createSwap(matchHash = "missing", status = ExpressExchangeStatus.Waiting))
// Act
val result = mergeTxHistoryInfos(onChain = emptyList(), express = express)
// Assert
assertThat(result).hasSize(1)
assertThat((result.single() as ExpressTx).txInfo).isNull()
}
@Test
fun `GIVEN unmatched terminal express op WHEN merge THEN standalone row kept`() {
// Arrange
val express = listOf(createSwap(matchHash = "missing", status = ExpressExchangeStatus.Finished))
// Act
val result = mergeTxHistoryInfos(onChain = emptyList(), express = express)
// Assert
assertThat(result).hasSize(1)
val row = result.single()
assertThat(row).isInstanceOf(ExpressTx.Swap::class.java)
assertThat((row as ExpressTx).txInfo).isNull()
}
@Test
fun `GIVEN on-chain tx unclaimed by express WHEN merge THEN passed through as OnChain`() {
// Arrange
val onChain = listOf(createTxInfo(txHash = "h1", timestamp = 100))
// Act
val result = mergeTxHistoryInfos(onChain, express = emptyList())
// Assert
assertThat(result).hasSize(1)
assertThat(result.single()).isInstanceOf(OnChainTx.BSDK::class.java)
}
@Test
fun `GIVEN rows of different timestamps WHEN merge THEN sorted by timestamp descending`() {
// Arrange
val onChain = listOf(createTxInfo(txHash = "h1", timestamp = 100))
val express = listOf(
createSwap(matchHash = "missing", createdAtMillis = 200, status = ExpressExchangeStatus.Waiting),
)
// Act
val result = mergeTxHistoryInfos(onChain, express)
// Assert
assertThat(result.map { it.timestampMillis }).containsExactly(200L, 100L).inOrder()
}
private fun createTxInfo(txHash: String, timestamp: Long) = TxInfo(
txHash = txHash,
timestampInMillis = timestamp,
isOutgoing = true,
destinationType = TxInfo.DestinationType.Single(TxInfo.AddressType.User("addr")),
sourceType = TxInfo.SourceType.Single("addr"),
interactionAddressType = null,
status = TxInfo.TransactionStatus.Confirmed,
type = TxInfo.TransactionType.Transfer,
amount = BigDecimal.ONE,
)
private fun createSwap(
matchHash: String?,
status: ExpressExchangeStatus,
createdAtMillis: Long = 100,
isOutgoing: Boolean = true,
) = ExpressTx.Swap(
tx = ExchangeTransaction(
txId = "tx-1",
status = status,
createdAtMillis = createdAtMillis,
provider = null,
payinHash = matchHash.takeIf { isOutgoing },
payoutHash = matchHash.takeUnless { isOutgoing },
fromAddress = null,
payoutAddress = null,
fromAsset = ExpressTransactionAsset(
id = ExpressAssetId(networkId = "eth", contractAddress = "0"),
amount = BigDecimal("1.5"),
decimals = 18,
),
toAsset = ExpressTransactionAsset(
id = ExpressAssetId(networkId = "btc", contractAddress = "0xt"),
amount = BigDecimal("0.001"),
decimals = 8,
),
),
isOutgoing = isOutgoing,
txInfo = null,
)
}