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

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