From 5b25eb63125663c0c0cbd3b8416636581a8d14f6 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 22 Sep 2025 14:29:23 +0500 Subject: [PATCH] Updated on 2026-08-14 --- .../di/TxHistoryItemsStoreModule.kt | 10 + .../DefaultTangemPayTxHistoryItemsStore.kt | 24 +++ .../visa/TangemPayTxHistoryItemsStore.kt | 13 ++ .../tangem/data/pay/di/TangemPayDataModule.kt | 6 + .../DefaultTangemPayTxHistoryRepository.kt | 181 ++++++++++++++++++ .../visa/utils/VisaTxHistoryItemConverter.kt | 20 ++ .../visa/utils/VisaTxHistoryItemFactory.kt | 19 -- .../visa/utils/VisaTxHistoryPagingSource.kt | 4 +- domain/visa/build.gradle.kts | 15 +- .../domain/visa/model/VisaTxHistoryItem.kt | 4 +- .../model/TangemPayTxHistoryListConfig.kt | 5 + .../model/TangemPayTxHistoryTypeAliases.kt | 9 + .../TangemPayTxHistoryRepository.kt | 11 ++ .../tangempay/details/impl/build.gradle.kts | 2 + .../model/TangemPayTxHistoryModel.kt | 100 ++++++++-- .../TangemPayTxHistoryItemsConverter.kt | 41 ++++ .../utils/TangemPayTxHistoryListManager.kt | 85 ++++++++ .../utils/TangemPayTxHistoryState.kt | 10 + .../utils/TangemPayTxHistoryUiManager.kt | 122 ++++++++++++ .../txhistory/utils/TxHistoryUiActions.kt | 2 +- 20 files changed, 635 insertions(+), 48 deletions(-) create mode 100644 core/datasource/src/main/java/com/tangem/datasource/local/visa/DefaultTangemPayTxHistoryItemsStore.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/local/visa/TangemPayTxHistoryItemsStore.kt create mode 100644 data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPayTxHistoryRepository.kt create mode 100644 data/visa/src/main/kotlin/com/tangem/data/visa/utils/VisaTxHistoryItemConverter.kt delete mode 100644 data/visa/src/main/kotlin/com/tangem/data/visa/utils/VisaTxHistoryItemFactory.kt rename domain/visa/{ => models}/src/main/kotlin/com/tangem/domain/visa/model/VisaTxHistoryItem.kt (87%) create mode 100644 domain/visa/src/main/kotlin/com/tangem/domain/tangempay/model/TangemPayTxHistoryListConfig.kt create mode 100644 domain/visa/src/main/kotlin/com/tangem/domain/tangempay/model/TangemPayTxHistoryTypeAliases.kt create mode 100644 domain/visa/src/main/kotlin/com/tangem/domain/tangempay/repository/TangemPayTxHistoryRepository.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayTxHistoryItemsConverter.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayTxHistoryListManager.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayTxHistoryState.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayTxHistoryUiManager.kt rename features/txhistory/{impl => api}/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryUiActions.kt (73%) diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/TxHistoryItemsStoreModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/TxHistoryItemsStoreModule.kt index aa6dd40222..283ea6c473 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/TxHistoryItemsStoreModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/TxHistoryItemsStoreModule.kt @@ -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(), + ) + } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/visa/DefaultTangemPayTxHistoryItemsStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/visa/DefaultTangemPayTxHistoryItemsStore.kt new file mode 100644 index 0000000000..25a8c85188 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/visa/DefaultTangemPayTxHistoryItemsStore.kt @@ -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>>, +) : TangemPayTxHistoryItemsStore, + StringKeyDataStoreDecorator>>(dataStore) { + override fun provideStringKey(key: UserWalletId): String = key.stringValue + + override suspend fun getSyncOrNull(key: UserWalletId, offset: Int): List? { + val storedValue = getSyncOrNull(key) + return storedValue?.get(offset) + } + + override suspend fun store(key: UserWalletId, offset: Int, value: List) { + val oldValue = getSyncOrNull(key).orEmpty() + val newValue = oldValue.toMutableMap().apply { put(offset, value) } + store(key, newValue) + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/visa/TangemPayTxHistoryItemsStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/visa/TangemPayTxHistoryItemsStore.kt new file mode 100644 index 0000000000..0d81b695f5 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/visa/TangemPayTxHistoryItemsStore.kt @@ -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? + + suspend fun remove(key: UserWalletId) + + suspend fun store(key: UserWalletId, offset: Int, value: List) +} \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt index c2d3ece8ad..7519633126 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt @@ -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 } \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPayTxHistoryRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPayTxHistoryRepository.kt new file mode 100644 index 0000000000..08447ba6a5 --- /dev/null +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPayTxHistoryRepository.kt @@ -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> { + 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 { + 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, + ) + } +} \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/visa/utils/VisaTxHistoryItemConverter.kt b/data/visa/src/main/kotlin/com/tangem/data/visa/utils/VisaTxHistoryItemConverter.kt new file mode 100644 index 0000000000..6311b23e70 --- /dev/null +++ b/data/visa/src/main/kotlin/com/tangem/data/visa/utils/VisaTxHistoryItemConverter.kt @@ -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 { + + 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), + ) + } +} \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/visa/utils/VisaTxHistoryItemFactory.kt b/data/visa/src/main/kotlin/com/tangem/data/visa/utils/VisaTxHistoryItemFactory.kt deleted file mode 100644 index d7be726e36..0000000000 --- a/data/visa/src/main/kotlin/com/tangem/data/visa/utils/VisaTxHistoryItemFactory.kt +++ /dev/null @@ -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), - ) - } -} \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/visa/utils/VisaTxHistoryPagingSource.kt b/data/visa/src/main/kotlin/com/tangem/data/visa/utils/VisaTxHistoryPagingSource.kt index 44649d0bf8..01950d27b7 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/visa/utils/VisaTxHistoryPagingSource.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/visa/utils/VisaTxHistoryPagingSource.kt @@ -20,8 +20,6 @@ internal class VisaTxHistoryPagingSource( val requestTxHistory: suspend (offset: Int, pageSize: Int) -> VisaTxHistoryResponse, ) : PagingSource() { - 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) } } } diff --git a/domain/visa/build.gradle.kts b/domain/visa/build.gradle.kts index 921d77a54c..e3bc73d03b 100644 --- a/domain/visa/build.gradle.kts +++ b/domain/visa/build.gradle.kts @@ -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) diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/visa/model/VisaTxHistoryItem.kt b/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/VisaTxHistoryItem.kt similarity index 87% rename from domain/visa/src/main/kotlin/com/tangem/domain/visa/model/VisaTxHistoryItem.kt rename to domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/VisaTxHistoryItem.kt index 6d3bcc876a..154766e32c 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/visa/model/VisaTxHistoryItem.kt +++ b/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/VisaTxHistoryItem.kt @@ -12,4 +12,6 @@ data class VisaTxHistoryItem( val merchantName: String?, val status: String, val fiatCurrency: Currency, -) \ No newline at end of file +) { + val timeStampInMillis: Long = date.millis +} \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/tangempay/model/TangemPayTxHistoryListConfig.kt b/domain/visa/src/main/kotlin/com/tangem/domain/tangempay/model/TangemPayTxHistoryListConfig.kt new file mode 100644 index 0000000000..e62a78f59a --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/tangempay/model/TangemPayTxHistoryListConfig.kt @@ -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) \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/tangempay/model/TangemPayTxHistoryTypeAliases.kt b/domain/visa/src/main/kotlin/com/tangem/domain/tangempay/model/TangemPayTxHistoryTypeAliases.kt new file mode 100644 index 0000000000..6a830fffdc --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/tangempay/model/TangemPayTxHistoryTypeAliases.kt @@ -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 + +typealias TangemPayTxHistoryListBatchFlow = BatchFlow, Nothing> \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/tangempay/repository/TangemPayTxHistoryRepository.kt b/domain/visa/src/main/kotlin/com/tangem/domain/tangempay/repository/TangemPayTxHistoryRepository.kt new file mode 100644 index 0000000000..7915f1b83d --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/tangempay/repository/TangemPayTxHistoryRepository.kt @@ -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 +} \ No newline at end of file diff --git a/features/tangempay/details/impl/build.gradle.kts b/features/tangempay/details/impl/build.gradle.kts index 84f577edf1..43583af755 100644 --- a/features/tangempay/details/impl/build.gradle.kts +++ b/features/tangempay/details/impl/build.gradle.kts @@ -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) diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayTxHistoryModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayTxHistoryModel.kt index 1916f32e69..998d340f7e 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayTxHistoryModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayTxHistoryModel.kt @@ -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 - field = MutableStateFlow(getInitialState()) + field = MutableStateFlow(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) { + 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) } } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayTxHistoryItemsConverter.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayTxHistoryItemsConverter.kt new file mode 100644 index 0000000000..d81befd5b0 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayTxHistoryItemsConverter.kt @@ -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 { + 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) }, + ) + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayTxHistoryListManager.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayTxHistoryListManager.kt new file mode 100644 index 0000000000..67d9ff9635 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayTxHistoryListManager.kt @@ -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 + +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 = MutableSharedFlow( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + private val state: MutableStateFlow = MutableStateFlow(TangemPayTxHistoryState()) + private val uiManager = TangemPayTxHistoryUiManager(state = state, txHistoryUiActions = txHistoryUiActions) + + val uiItems: Flow> = uiManager.items + val paginationStatus: Flow> = 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>) { + state.update { state -> + val clearUiBatches = + state.status is PaginationStatus.InitialLoading && batchListState.status is PaginationStatus.Paginating + state.copy( + status = batchListState.status, + uiBatches = uiManager.createOrUpdateUiBatches( + newCurrencyBatches = batchListState.data, + clearUiBatches = clearUiBatches, + ), + ) + } + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayTxHistoryState.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayTxHistoryState.kt new file mode 100644 index 0000000000..b9b28372b3 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayTxHistoryState.kt @@ -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>> = listOf(), +) \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayTxHistoryUiManager.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayTxHistoryUiManager.kt new file mode 100644 index 0000000000..316815be75 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayTxHistoryUiManager.kt @@ -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, + private val txHistoryUiActions: TxHistoryUiActions, +) { + + @OptIn(ExperimentalCoroutinesApi::class) + val items: Flow> = 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>>, + clearUiBatches: Boolean, + ): List>> { + val currentUiBatches = state.value.uiBatches + val batches = if (clearUiBatches) mutableListOf() else currentUiBatches.toMutableList() + + for ((key, data) in newCurrencyBatches) { + // Find if batch with same key exists + val existingBatchIndex = batches.indexOfFirst { it.key == key } + val shouldUpdateExisting = existingBatchIndex != -1 && + currentUiBatches[existingBatchIndex].data.transactionItemsSizeNotEqual(data) + + // 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): List { + val items = mutableListOf() + + // Add title for the first batch + if (key == 0) { + items.add(TxHistoryUM.TxHistoryItemUM.Title(onExploreClick = txHistoryUiActions::openExplorer)) + } + + // Process batch items only if there are any + if (data.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.transactionItemsSizeNotEqual( + txInfos: List, + ): Boolean { + return this.filterIsInstance().size != txInfos.size + } +} \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryUiActions.kt b/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryUiActions.kt similarity index 73% rename from features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryUiActions.kt rename to features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryUiActions.kt index 57a50a9014..9f7922aeda 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryUiActions.kt +++ b/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryUiActions.kt @@ -1,6 +1,6 @@ package com.tangem.features.txhistory.utils -internal interface TxHistoryUiActions { +interface TxHistoryUiActions { fun openExplorer() fun openTxInExplorer(txHash: String)