Updated on 2026-08-14

This commit is contained in:
Tangem 2025-09-22 14:29:23 +05:00
parent b98b66ff9f
commit 5b25eb6312
20 changed files with 635 additions and 48 deletions

View file

@ -3,6 +3,8 @@ package com.tangem.datasource.di
import com.tangem.datasource.local.datastore.RuntimeDataStore
import com.tangem.datasource.local.txhistory.DefaultTxHistoryItemsStore
import com.tangem.datasource.local.txhistory.TxHistoryItemsStore
import com.tangem.datasource.local.visa.DefaultTangemPayTxHistoryItemsStore
import com.tangem.datasource.local.visa.TangemPayTxHistoryItemsStore
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -20,4 +22,12 @@ internal object TxHistoryItemsStoreModule {
dataStore = RuntimeDataStore(),
)
}
@Provides
@Singleton
fun provideTangemPayTxHistoryItemsStore(): TangemPayTxHistoryItemsStore {
return DefaultTangemPayTxHistoryItemsStore(
dataStore = RuntimeDataStore(),
)
}
}

View file

@ -0,0 +1,24 @@
package com.tangem.datasource.local.visa
import com.tangem.datasource.local.datastore.core.StringKeyDataStore
import com.tangem.datasource.local.datastore.core.StringKeyDataStoreDecorator
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.visa.model.VisaTxHistoryItem
internal class DefaultTangemPayTxHistoryItemsStore(
dataStore: StringKeyDataStore<Map<Int, List<VisaTxHistoryItem>>>,
) : TangemPayTxHistoryItemsStore,
StringKeyDataStoreDecorator<UserWalletId, Map<Int, List<VisaTxHistoryItem>>>(dataStore) {
override fun provideStringKey(key: UserWalletId): String = key.stringValue
override suspend fun getSyncOrNull(key: UserWalletId, offset: Int): List<VisaTxHistoryItem>? {
val storedValue = getSyncOrNull(key)
return storedValue?.get(offset)
}
override suspend fun store(key: UserWalletId, offset: Int, value: List<VisaTxHistoryItem>) {
val oldValue = getSyncOrNull(key).orEmpty()
val newValue = oldValue.toMutableMap().apply { put(offset, value) }
store(key, newValue)
}
}

View file

@ -0,0 +1,13 @@
package com.tangem.datasource.local.visa
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.visa.model.VisaTxHistoryItem
interface TangemPayTxHistoryItemsStore {
suspend fun getSyncOrNull(key: UserWalletId, offset: Int): List<VisaTxHistoryItem>?
suspend fun remove(key: UserWalletId)
suspend fun store(key: UserWalletId, offset: Int, value: List<VisaTxHistoryItem>)
}

View file

@ -1,9 +1,11 @@
package com.tangem.data.pay.di
import com.tangem.data.pay.repository.DefaultKycRepository
import com.tangem.data.pay.repository.DefaultTangemPayTxHistoryRepository
import com.tangem.data.pay.repository.DefaultOnboardingRepository
import com.tangem.domain.pay.repository.KycRepository
import com.tangem.domain.pay.repository.OnboardingRepository
import com.tangem.domain.tangempay.repository.TangemPayTxHistoryRepository
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
@ -21,4 +23,8 @@ internal interface TangemPayDataModule {
@Binds
@Singleton
fun bindOnboardingRepository(repository: DefaultOnboardingRepository): OnboardingRepository
@Binds
@Singleton
fun bindTangemPayTxHistoryRepository(repository: DefaultTangemPayTxHistoryRepository): TangemPayTxHistoryRepository
}

View file

@ -0,0 +1,181 @@
package com.tangem.data.pay.repository
import com.tangem.data.common.cache.CacheRegistry
import com.tangem.data.visa.utils.VisaApiRequestMaker
import com.tangem.data.visa.utils.VisaTxHistoryItemConverter
import com.tangem.datasource.api.pay.TangemPayApi
import com.tangem.datasource.api.pay.models.response.VisaTxHistoryResponse
import com.tangem.datasource.local.visa.TangemPayTxHistoryItemsStore
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.tangempay.model.TangemPayTxHistoryListBatchFlow
import com.tangem.domain.tangempay.model.TangemPayTxHistoryListBatchingContext
import com.tangem.domain.tangempay.model.TangemPayTxHistoryListConfig
import com.tangem.domain.tangempay.repository.TangemPayTxHistoryRepository
import com.tangem.domain.visa.model.VisaTxHistoryItem
import com.tangem.pagination.BatchFetchResult
import com.tangem.pagination.BatchListSource
import com.tangem.pagination.fetcher.LimitOffsetBatchFetcher
import com.tangem.pagination.toBatchFlow
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.delay
import org.joda.time.DateTime
import java.math.BigDecimal
import javax.inject.Inject
@Suppress("UnusedPrivateMember", "MagicNumber", "UnnecessaryParentheses")
internal class DefaultTangemPayTxHistoryRepository @Inject constructor(
private val visaApiRequestMaker: VisaApiRequestMaker,
private val visaApi: TangemPayApi,
private val cacheRegistry: CacheRegistry,
private val txHistoryItemsStore: TangemPayTxHistoryItemsStore,
private val dispatchers: CoroutineDispatcherProvider,
) : TangemPayTxHistoryRepository {
override fun getTxHistoryBatchFlow(
batchSize: Int,
context: TangemPayTxHistoryListBatchingContext,
): TangemPayTxHistoryListBatchFlow {
return BatchListSource(
fetchDispatcher = dispatchers.io,
context = context,
generateNewKey = { keys -> keys.lastOrNull()?.inc() ?: 0 },
batchFetcher = createFetcher(batchSize),
).toBatchFlow()
}
private fun createFetcher(
batchSize: Int,
): LimitOffsetBatchFetcher<TangemPayTxHistoryListConfig, List<VisaTxHistoryItem>> {
return LimitOffsetBatchFetcher(
prefetchDistance = batchSize,
batchSize = batchSize,
subFetcher = { config, _, isInitialLoading ->
val items = loadItems(config = config.params, offset = config.offset, limit = config.limit)
BatchFetchResult.Success(
data = items,
last = items.size < batchSize,
empty = items.isEmpty(),
)
},
)
}
private suspend fun loadItems(
config: TangemPayTxHistoryListConfig,
offset: Int,
limit: Int,
): List<VisaTxHistoryItem> {
cacheRegistry.invokeOnExpire(
key = getCacheKey(userWalletId = config.userWalletId, offset = offset),
skipCache = config.refresh,
// TODO: TangemPay uncomment while BFF will be ready
// block = { fetch(userWalletId = config.userWalletId, offset = offset, pageSize = limit) },
block = { fetchMocked(userWalletId = config.userWalletId) },
)
return txHistoryItemsStore.getSyncOrNull(key = config.userWalletId, offset = offset).orEmpty()
}
private fun getCacheKey(userWalletId: UserWalletId, offset: Int): String {
return "tangem_pay_tx_history_${userWalletId}_$offset"
}
private suspend fun fetch(userWalletId: UserWalletId, offset: Int, pageSize: Int) {
val response = visaApiRequestMaker.request(userWalletId = userWalletId) { authHeader, accessCodeData ->
visaApi.getTxHistory(
authHeader = authHeader,
customerId = accessCodeData.customerId,
productInstanceId = accessCodeData.productInstanceId,
limit = pageSize,
offset = offset,
)
}
val items = VisaTxHistoryItemConverter.convertList(response.transactions)
txHistoryItemsStore.store(key = userWalletId, offset = offset, value = items)
}
private suspend fun fetchMocked(userWalletId: UserWalletId) {
delay(2000) // initiating network request
val items = VisaTxHistoryItemConverter.convertList(MOCKED_RESPONSE.transactions)
txHistoryItemsStore.store(key = userWalletId, offset = 0, value = items)
}
companion object {
private val now = DateTime.now()
private var transactionIdCounter = 1000L
private val todayTransactions = List(2) { index ->
VisaTxHistoryResponse.Transaction(
transactionId = transactionIdCounter++,
transactionDt = now.minusHours(index + 1).minusMinutes(index * 15),
transactionStatus = "Completed", // As "Success" might map to "Completed"
transactionType = "Purchase",
billingAmount = BigDecimal("${10 + index * 5}.${20 + index * 3}"),
billingCurrencyCode = 840, // USD
transactionAmount = BigDecimal("${10 + index * 5}.${20 + index * 3}"),
transactionCurrencyCode = 840, // USD
merchantName = "Online Store ${'A' + index}",
merchantCity = "San Francisco",
merchantCountryCode = "US",
merchantCategoryCode = "5411", // Grocery Stores
authCode = "AUTH${12345 + index}",
rrn = "RRN00${100 + index}",
blockchainAmount = BigDecimal("0.001").multiply(BigDecimal(index + 1)),
blockchainCoinName = "ETH",
blockchainFee = BigDecimal("0.00005"),
requests = emptyList(),
)
}
private val yesterdayTransactions = List(4) { index ->
VisaTxHistoryResponse.Transaction(
transactionId = transactionIdCounter++,
transactionDt = now.minusDays(1).withTime(10 + index * 2, 15 * index % 60, index * 5 % 60, 0),
transactionStatus = "Completed",
transactionType = "Purchase",
billingAmount = BigDecimal("${20 + index * 7}.${10 + index * 2}"),
billingCurrencyCode = 840, // USD
transactionAmount = BigDecimal("${20 + index * 7}.${10 + index * 2}"),
transactionCurrencyCode = 840, // USD
merchantName = "Coffee Shop ${'X' + index}",
merchantCity = "Berlin",
merchantCountryCode = "DE",
merchantCategoryCode = "5812", // Restaurants
authCode = "AUTH${22345 + index}",
rrn = "RRN00${200 + index}",
blockchainAmount = BigDecimal("0.001").multiply(BigDecimal(index + 1)),
blockchainCoinName = "ETH",
blockchainFee = BigDecimal("0.00005"),
requests = emptyList(),
)
}
private val threeDaysAgoTransactions = List(10) { index ->
VisaTxHistoryResponse.Transaction(
transactionId = transactionIdCounter++,
transactionDt = now.minusDays(3).withTime(9 + index, (index * 10) % 60, (index * 20) % 60, 0),
transactionStatus = "Completed",
transactionType = "Purchase",
billingAmount = BigDecimal("${5 + index * 2}.${50 + index}"),
billingCurrencyCode = 840, // USD
transactionAmount = BigDecimal("${5 + index * 2}.${50 + index}"),
transactionCurrencyCode = 840, // USD
merchantName = "Gadget Store ${'M' + index}",
merchantCity = if (index % 2 == 0) "London" else "Paris",
merchantCountryCode = if (index % 2 == 0) "GB" else "FR",
merchantCategoryCode = "5732", // Electronic Sales
authCode = "AUTH${32345 + index}",
rrn = "RRN00${300 + index}",
blockchainAmount = BigDecimal("0.001").multiply(BigDecimal(index + 1)),
blockchainCoinName = "ETH",
blockchainFee = BigDecimal("0.00005"),
requests = emptyList(),
)
}
private val MOCKED_RESPONSE = VisaTxHistoryResponse(
cardWalletAddress = "0xYourVisaVirtualCardAddressHere",
transactions = todayTransactions + yesterdayTransactions + threeDaysAgoTransactions,
)
}
}

View file

@ -0,0 +1,20 @@
package com.tangem.data.visa.utils
import com.tangem.datasource.api.pay.models.response.VisaTxHistoryResponse
import com.tangem.domain.visa.model.VisaTxHistoryItem
import com.tangem.utils.converter.Converter
internal object VisaTxHistoryItemConverter : Converter<VisaTxHistoryResponse.Transaction, VisaTxHistoryItem> {
override fun convert(value: VisaTxHistoryResponse.Transaction): VisaTxHistoryItem {
return VisaTxHistoryItem(
id = value.transactionId.toString(),
date = value.transactionDt,
amount = value.blockchainAmount,
fiatAmount = value.transactionAmount,
merchantName = value.merchantName,
status = value.transactionStatus,
fiatCurrency = findCurrencyByNumericCode(value.transactionCurrencyCode),
)
}
}

View file

@ -1,19 +0,0 @@
package com.tangem.data.visa.utils
import com.tangem.datasource.api.pay.models.response.VisaTxHistoryResponse
import com.tangem.domain.visa.model.VisaTxHistoryItem
internal class VisaTxHistoryItemFactory {
fun create(transaction: VisaTxHistoryResponse.Transaction): VisaTxHistoryItem {
return VisaTxHistoryItem(
id = transaction.transactionId.toString(),
date = transaction.transactionDt,
amount = transaction.blockchainAmount,
fiatAmount = transaction.transactionAmount,
merchantName = transaction.merchantName,
status = transaction.transactionStatus,
fiatCurrency = findCurrencyByNumericCode(transaction.transactionCurrencyCode),
)
}
}

View file

@ -20,8 +20,6 @@ internal class VisaTxHistoryPagingSource(
val requestTxHistory: suspend (offset: Int, pageSize: Int) -> VisaTxHistoryResponse,
) : PagingSource<Int, VisaTxHistoryItem>() {
private val itemsFactory = VisaTxHistoryItemFactory()
private val cardPublicKey = params.cardPublicKey
private val pageSize = params.pageSize
private val isRefresh = params.isRefresh
@ -82,7 +80,7 @@ internal class VisaTxHistoryPagingSource(
pagedItems.update {
it.toMutableMap().apply {
this[offset] = response.transactions.map(itemsFactory::create)
this[offset] = response.transactions.map(VisaTxHistoryItemConverter::convert)
}
}
}

View file

@ -10,17 +10,18 @@ android {
}
dependencies {
/** Domain models */
api(projects.domain.visa.models)
/** Project - Domain */
/** Project - Core */
api(projects.core.pagination)
implementation(projects.core.utils)
implementation(projects.core.error)
/** Project - Domain */
api(projects.domain.models)
implementation(projects.domain.core)
implementation(projects.domain.wallets.models)
implementation(projects.domain.tokens.models)
api(projects.domain.visa.models)
implementation(projects.domain.appCurrency.models)
implementation(projects.domain.core)
implementation(projects.domain.tokens.models)
implementation(projects.domain.wallets.models)
/** Security */
implementation(deps.spongecastle.core)

View file

@ -12,4 +12,6 @@ data class VisaTxHistoryItem(
val merchantName: String?,
val status: String,
val fiatCurrency: Currency,
)
) {
val timeStampInMillis: Long = date.millis
}

View file

@ -0,0 +1,5 @@
package com.tangem.domain.tangempay.model
import com.tangem.domain.models.wallet.UserWalletId
data class TangemPayTxHistoryListConfig(val userWalletId: UserWalletId, val refresh: Boolean)

View file

@ -0,0 +1,9 @@
package com.tangem.domain.tangempay.model
import com.tangem.domain.visa.model.VisaTxHistoryItem
import com.tangem.pagination.BatchFlow
import com.tangem.pagination.BatchingContext
typealias TangemPayTxHistoryListBatchingContext = BatchingContext<Int, TangemPayTxHistoryListConfig, Nothing>
typealias TangemPayTxHistoryListBatchFlow = BatchFlow<Int, List<VisaTxHistoryItem>, Nothing>

View file

@ -0,0 +1,11 @@
package com.tangem.domain.tangempay.repository
import com.tangem.domain.tangempay.model.TangemPayTxHistoryListBatchFlow
import com.tangem.domain.tangempay.model.TangemPayTxHistoryListBatchingContext
interface TangemPayTxHistoryRepository {
fun getTxHistoryBatchFlow(
batchSize: Int,
context: TangemPayTxHistoryListBatchingContext,
): TangemPayTxHistoryListBatchFlow
}

View file

@ -25,6 +25,8 @@ dependencies {
implementation(projects.domain.balanceHiding)
implementation(projects.domain.balanceHiding.models)
implementation(projects.domain.models)
implementation(projects.domain.visa)
implementation(projects.domain.visa.models)
/** Compose */
implementation(deps.compose.foundation)

View file

@ -5,16 +5,15 @@ import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
import com.tangem.domain.tangempay.repository.TangemPayTxHistoryRepository
import com.tangem.features.tangempay.components.txHistory.DefaultTangemPayTxHistoryComponent
import com.tangem.features.tangempay.components.txHistory.PreviewTangemPayTxHistoryComponent
import com.tangem.features.tangempay.utils.TangemPayTxHistoryListManager
import com.tangem.features.txhistory.entity.TxHistoryUM
import com.tangem.features.txhistory.utils.TxHistoryUiActions
import com.tangem.pagination.PaginationStatus
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@ -24,39 +23,106 @@ import javax.inject.Inject
internal class TangemPayTxHistoryModel @Inject constructor(
private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase,
override val dispatchers: CoroutineDispatcherProvider,
tangemPayTxHistoryRepository: TangemPayTxHistoryRepository,
paramsContainer: ParamsContainer,
) : Model() {
) : Model(), TxHistoryUiActions {
private val params: DefaultTangemPayTxHistoryComponent.Params = paramsContainer.require()
private val listManager = TangemPayTxHistoryListManager(
repository = tangemPayTxHistoryRepository,
dispatchers = dispatchers,
userWalletId = params.userWalletId,
txHistoryUiActions = this,
)
val uiState: StateFlow<TxHistoryUM>
field = MutableStateFlow(getInitialState())
field = MutableStateFlow<TxHistoryUM>(getLoadingState(isBalanceHidden = true))
init {
handleBalanceHiding()
launchPagination()
subscribeToUiItemChanges()
}
@Suppress("MagicNumber")
private fun launchPagination() {
modelScope.launch { listManager.launchPagination() }
}
private fun subscribeToUiItemChanges() {
modelScope.launch {
Timber.d("subscribeToUiItemChanges: ${params.userWalletId}")
delay(2000)
uiState.update { PreviewTangemPayTxHistoryComponent.contentUM }
listManager.uiItems
.onEach { updateState(it) }
.launchIn(modelScope)
listManager.paginationStatus
.onEach { paginationStatus -> handlePaginationStatus(paginationStatus) }
.launchIn(modelScope)
}
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 handlePaginationStatus(status: PaginationStatus<*>) {
uiState.update { state ->
when (status) {
is PaginationStatus.InitialLoadingError -> getErrorState(state.isBalanceHidden)
PaginationStatus.EndOfPagination,
PaginationStatus.InitialLoading,
PaginationStatus.NextBatchLoading,
PaginationStatus.None,
is PaginationStatus.Paginating<*>,
-> state
}
}
}
private fun loadMoreItems(): Boolean {
modelScope.launch { listManager.loadMore(params.userWalletId) }
return true
}
fun reload() {
// fast exit
if (uiState.value is TxHistoryUM.NotSupported) return
uiState.update { state ->
state as? TxHistoryUM.Content ?: getLoadingState(state.isBalanceHidden)
}
modelScope.launch { listManager.reload() }
}
private fun handleBalanceHiding() {
getBalanceHidingSettingsUseCase()
.onEach { uiState.update { state -> state.copySealed(isBalanceHidden = it.isBalanceHidden) } }
.launchIn(modelScope)
}
private fun onExploreClick() {
override fun openExplorer() {
Timber.d("onExploreClick: open explorer")
}
private fun getInitialState(): TxHistoryUM {
return TxHistoryUM.Loading(isBalanceHidden = true, onExploreClick = ::onExploreClick)
override fun openTxInExplorer(txHash: String) {
Timber.d("openTxInExplorer: $txHash")
}
private fun getErrorState(isBalanceHidden: Boolean): TxHistoryUM.Error {
return TxHistoryUM.Error(
isBalanceHidden = isBalanceHidden,
onReloadClick = ::reload,
onExploreClick = ::openExplorer,
)
}
private fun getLoadingState(isBalanceHidden: Boolean): TxHistoryUM.Loading {
return TxHistoryUM.Loading(isBalanceHidden = isBalanceHidden, onExploreClick = ::openExplorer)
}
}

View file

@ -0,0 +1,41 @@
package com.tangem.features.tangempay.model.transformers
import com.tangem.core.ui.components.transactions.state.TransactionState
import com.tangem.core.ui.extensions.capitalize
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.utils.DateTimeFormatters
import com.tangem.domain.visa.model.VisaTxHistoryItem
import com.tangem.features.tangempay.details.impl.R
import com.tangem.features.txhistory.utils.TxHistoryUiActions
import com.tangem.utils.StringsSigns
import com.tangem.utils.converter.Converter
import org.joda.time.DateTimeZone
internal class TangemPayTxHistoryItemsConverter(
private val txHistoryUiActions: TxHistoryUiActions,
) : Converter<VisaTxHistoryItem, TransactionState> {
override fun convert(value: VisaTxHistoryItem): TransactionState {
val localDate = value.date.withZone(DateTimeZone.getDefault())
val fiatAmount = value.fiatAmount.format {
fiat(
fiatCurrencyCode = value.fiatCurrency.currencyCode,
fiatCurrencySymbol = value.fiatCurrency.symbol,
)
}
return TransactionState.Content(
txHash = value.id,
amount = "${StringsSigns.MINUS}$fiatAmount",
time = DateTimeFormatters.formatDate(localDate, DateTimeFormatters.timeFormatter),
status = TransactionState.Content.Status.Confirmed,
direction = TransactionState.Content.Direction.OUTGOING,
iconRes = R.drawable.ic_arrow_up_24,
title = stringReference(value = value.merchantName?.capitalize() ?: "Unknown merchant"),
subtitle = stringReference("How to get merchant type?"),
timestamp = localDate.millis,
onClick = { txHistoryUiActions.openTxInExplorer(value.id) },
)
}
}

View file

@ -0,0 +1,85 @@
package com.tangem.features.tangempay.utils
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.tangempay.model.TangemPayTxHistoryListBatchingContext
import com.tangem.domain.tangempay.model.TangemPayTxHistoryListConfig
import com.tangem.domain.tangempay.repository.TangemPayTxHistoryRepository
import com.tangem.domain.visa.model.VisaTxHistoryItem
import com.tangem.features.txhistory.entity.TxHistoryUM
import com.tangem.features.txhistory.utils.TxHistoryUiActions
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 TangemPayTxHistoryBatchAction = BatchAction<Int, TangemPayTxHistoryListConfig, Nothing>
internal class TangemPayTxHistoryListManager(
private val repository: TangemPayTxHistoryRepository,
private val dispatchers: CoroutineDispatcherProvider,
private val userWalletId: UserWalletId,
private val txHistoryUiActions: TxHistoryUiActions,
) {
private val jobHolder = JobHolder()
private val actionsFlow: MutableSharedFlow<TangemPayTxHistoryBatchAction> = MutableSharedFlow(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
private val state: MutableStateFlow<TangemPayTxHistoryState> = MutableStateFlow(TangemPayTxHistoryState())
private val uiManager = TangemPayTxHistoryUiManager(state = state, txHistoryUiActions = txHistoryUiActions)
val uiItems: Flow<ImmutableList<TxHistoryUM.TxHistoryItemUM>> = uiManager.items
val paginationStatus: Flow<PaginationStatus<*>> = state.map { it.status }.distinctUntilChanged()
suspend fun launchPagination() = coroutineScope {
val batchFlow = repository.getTxHistoryBatchFlow(
context = TangemPayTxHistoryListBatchingContext(actionsFlow = actionsFlow, coroutineScope = this),
batchSize = 50,
)
batchFlow.state
.onEach { state -> updateState(state) }
.flowOn(dispatchers.default)
.launchIn(scope = this)
.saveIn(jobHolder)
// Initial load
reload()
}
suspend fun reload() {
actionsFlow.emit(
BatchAction.Reload(
requestParams = TangemPayTxHistoryListConfig(userWalletId = userWalletId, refresh = true),
),
)
}
suspend fun loadMore(userWalletId: UserWalletId) {
actionsFlow.emit(
BatchAction.LoadMore(
requestParams = TangemPayTxHistoryListConfig(userWalletId, refresh = false),
),
)
}
private fun updateState(batchListState: BatchListState<Int, List<VisaTxHistoryItem>>) {
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.tangempay.utils
import com.tangem.features.txhistory.entity.TxHistoryUM
import com.tangem.pagination.Batch
import com.tangem.pagination.PaginationStatus
data class TangemPayTxHistoryState(
val status: PaginationStatus<*> = PaginationStatus.None,
val uiBatches: List<Batch<Int, List<TxHistoryUM.TxHistoryItemUM>>> = listOf(),
)

View file

@ -0,0 +1,122 @@
package com.tangem.features.tangempay.utils
import com.tangem.core.ui.utils.toDateFormatWithTodayYesterday
import com.tangem.domain.visa.model.VisaTxHistoryItem
import com.tangem.features.tangempay.model.transformers.TangemPayTxHistoryItemsConverter
import com.tangem.features.txhistory.entity.TxHistoryUM
import com.tangem.features.txhistory.utils.TxHistoryUiActions
import com.tangem.pagination.Batch
import com.tangem.pagination.PaginationStatus
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.filter
import kotlinx.coroutines.flow.mapLatest
import java.util.UUID
internal class TangemPayTxHistoryUiManager(
private val state: MutableStateFlow<TangemPayTxHistoryState>,
private val txHistoryUiActions: TxHistoryUiActions,
) {
@OptIn(ExperimentalCoroutinesApi::class)
val items: Flow<ImmutableList<TxHistoryUM.TxHistoryItemUM>> = state
// filter initial states, since we dont emit loading items as UI items
.filter {
it.status !is PaginationStatus.None &&
it.status !is PaginationStatus.InitialLoading &&
it.status !is PaginationStatus.InitialLoadingError
}
.mapLatest { state ->
state.uiBatches.asSequence()
.flatMap { it.data }
.toImmutableList()
}
.distinctUntilChanged()
private val txHistoryItemConverter = TangemPayTxHistoryItemsConverter(txHistoryUiActions = txHistoryUiActions)
fun createOrUpdateUiBatches(
newCurrencyBatches: List<Batch<Int, List<VisaTxHistoryItem>>>,
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)
// 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: List<VisaTxHistoryItem>): 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.isNotEmpty()) {
// Add first item with its group title
val firstItem = data.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.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(
txInfos: List<VisaTxHistoryItem>,
): Boolean {
return this.filterIsInstance<TxHistoryUM.TxHistoryItemUM.Transaction>().size != txInfos.size
}
}

View file

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