Updated on 2026-08-14

This commit is contained in:
Tangem 2026-04-01 14:34:13 +05:00
parent 4c12570008
commit 97f41bd800
38 changed files with 768 additions and 163 deletions

View file

@ -4,7 +4,7 @@ import com.tangem.domain.account.status.usecase.ManageCryptoCurrenciesUseCase
import com.tangem.domain.tokensync.repository.TokenSyncRepository
import com.tangem.domain.tokensync.usecase.AcknowledgeTokenSyncCompletionUseCase
import com.tangem.domain.tokensync.usecase.ObserveTokenSyncUseCase
import com.tangem.domain.tokensync.usecase.SyncTokensUseCase
import com.tangem.domain.tokensync.usecase.StartTokenSyncUseCase
import com.tangem.utils.coroutines.AppCoroutineScope
import dagger.Module
import dagger.Provides
@ -36,12 +36,12 @@ internal object TokenSyncDomainModule {
@Provides
@Singleton
fun provideSyncTokensUseCase(
fun provideStartTokenSyncUseCase(
tokenSyncRepository: TokenSyncRepository,
manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase,
appCoroutineScope: AppCoroutineScope,
): SyncTokensUseCase {
return SyncTokensUseCase(
): StartTokenSyncUseCase {
return StartTokenSyncUseCase(
tokenSyncRepository = tokenSyncRepository,
manageCryptoCurrenciesUseCase = manageCryptoCurrenciesUseCase,
appCoroutineScope = appCoroutineScope,

View file

@ -138,7 +138,6 @@ internal class ChildFactory @Inject constructor(
AppRoute.ManageTokens.Source.SETTINGS -> ManageTokensSource.SETTINGS
AppRoute.ManageTokens.Source.STORIES -> ManageTokensSource.STORIES
AppRoute.ManageTokens.Source.ACCOUNT -> ManageTokensSource.ACCOUNT
AppRoute.ManageTokens.Source.TOKEN_SYNC_BANNER -> ManageTokensSource.TOKEN_SYNC_BANNER
}
val mode = route.accountId?.let { ManageTokensMode.Account(it) } ?: ManageTokensMode.None

View file

@ -146,7 +146,6 @@ sealed class AppRoute(val path: String) : Route {
STORIES,
SETTINGS,
ACCOUNT,
TOKEN_SYNC_BANNER,
}
}

View file

@ -11,14 +11,24 @@ android {
}
dependencies {
api(projects.domain.tokensync)
implementation(projects.domain.tokens)
implementation(projects.domain.tokens.models)
implementation(projects.domain.models)
implementation(projects.domain.walletManager)
implementation(projects.domain.wallets)
implementation(projects.data.common)
implementation(projects.libs.blockchainSdk)
implementation(projects.core.datasource)
implementation(projects.core.utils)
implementation(projects.domain.models)
implementation(tangemDeps.blockchain)
implementation(deps.androidx.datastore)
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
implementation(deps.androidx.datastore)
implementation(deps.kotlin.coroutines)
implementation(deps.moshi)
}

View file

@ -0,0 +1,49 @@
package com.tangem.data.tokensync.di
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory
import com.tangem.data.common.network.NetworkFactory
import com.tangem.data.tokensync.repository.DefaultTokenSyncRepository
import com.tangem.data.tokensync.store.TokenSyncStoreFactory
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.tokensync.repository.TokenSyncRepository
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
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 TokenSyncDataModule {
@Provides
@Singleton
fun provideTokenSyncRepository(
walletManagersFacade: WalletManagersFacade,
tangemTechApi: TangemTechApi,
userWalletsListRepository: UserWalletsListRepository,
networkFactory: NetworkFactory,
appPreferencesStore: AppPreferencesStore,
tokenSyncStoreFactory: TokenSyncStoreFactory,
responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory,
dispatchers: CoroutineDispatcherProvider,
excludedBlockchains: ExcludedBlockchains,
): TokenSyncRepository {
return DefaultTokenSyncRepository(
walletManagersFacade = walletManagersFacade,
tangemTechApi = tangemTechApi,
userWalletsListRepository = userWalletsListRepository,
networkFactory = networkFactory,
appPreferencesStore = appPreferencesStore,
tokenSyncStoreFactory = tokenSyncStoreFactory,
responseCryptoCurrenciesFactory = responseCryptoCurrenciesFactory,
dispatchers = dispatchers,
excludedBlockchains = excludedBlockchains,
)
}
}

View file

@ -0,0 +1,346 @@
package com.tangem.data.tokensync.repository
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.tokenbalance.models.TokenBalance
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory
import com.tangem.data.common.network.NetworkFactory
import com.tangem.data.tokensync.store.TokenSyncStore
import com.tangem.data.tokensync.store.TokenSyncStoreFactory
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.CoinsResponse
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getObjectMapSync
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.common.wallets.getSyncStrict
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.tokensync.model.TokenSyncProgress
import com.tangem.domain.tokensync.repository.TokenSyncRepository
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import java.math.BigDecimal
import java.util.concurrent.ConcurrentHashMap
@Suppress("LongParameterList")
internal class DefaultTokenSyncRepository(
private val walletManagersFacade: WalletManagersFacade,
private val tangemTechApi: TangemTechApi,
private val userWalletsListRepository: UserWalletsListRepository,
private val networkFactory: NetworkFactory,
private val appPreferencesStore: AppPreferencesStore,
private val tokenSyncStoreFactory: TokenSyncStoreFactory,
private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory,
private val dispatchers: CoroutineDispatcherProvider,
private val excludedBlockchains: ExcludedBlockchains,
) : TokenSyncRepository {
private val semaphore = Semaphore(MAX_CONCURRENT_REQUESTS)
private val progressStates = ConcurrentHashMap<String, MutableStateFlow<TokenSyncProgress>>()
override fun observeSyncProgress(userWalletId: UserWalletId): Flow<TokenSyncProgress> {
return getProgressFlow(userWalletId)
}
override fun acknowledgeCompletion(userWalletId: UserWalletId) {
val key = userWalletId.stringValue
val stateFlow = progressStates[key] ?: return
stateFlow.value = TokenSyncProgress.Idle
progressStates.remove(key, stateFlow)
}
override suspend fun getDiscoveredCurrencies(userWalletId: UserWalletId): List<CryptoCurrency> {
val tokenSyncStore = tokenSyncStoreFactory.provide(userWalletId)
val storedTokens = tokenSyncStore.get()
if (storedTokens.isEmpty()) return emptyList()
val userWallet = userWalletsListRepository.getSyncStrict(userWalletId)
return responseCryptoCurrenciesFactory.createCurrencies(
tokens = storedTokens,
userWallet = userWallet,
accountIndex = DerivationIndex.Main,
)
}
override suspend fun clearDiscoveredTokens(userWalletId: UserWalletId) {
val tokenSyncStore = tokenSyncStoreFactory.provide(userWalletId)
tokenSyncStore.clear()
}
override suspend fun clearPendingFlag(userWalletId: UserWalletId) {
setPendingFlag(userWalletId, value = false)
}
override suspend fun getPendingSyncWalletIds(): List<UserWalletId> {
val pendingMap = appPreferencesStore
.getObjectMapSync<Boolean>(PreferencesKeys.PENDING_DISCOVERY_SYNC_KEY)
return pendingMap
.filter { it.value }
.map { UserWalletId(it.key) }
}
override suspend fun runSync(userWalletId: UserWalletId) {
val networks = getSupportedNetworks(userWalletId)
if (networks.isEmpty()) return
setPendingFlag(userWalletId, value = true)
val tokenSyncStore = tokenSyncStoreFactory.provide(userWalletId)
tokenSyncStore.clear()
val batches = networks.chunked(MAX_CONCURRENT_REQUESTS)
var completedNetworks = 0
getProgressFlow(userWalletId).value = TokenSyncProgress.InProgress(
completedNetworks = 0,
totalNetworks = networks.size,
)
for (batch in batches) {
val batchResults = processBatch(userWalletId, batch)
completedNetworks = handleBatchResults(
userWalletId = userWalletId,
results = batchResults,
tokenSyncStore = tokenSyncStore,
completedNetworks = completedNetworks,
totalNetworks = networks.size,
)
}
}
override suspend fun completeSync(userWalletId: UserWalletId) {
setPendingFlag(userWalletId, value = false)
getProgressFlow(userWalletId).value = TokenSyncProgress.Completed
}
private suspend fun processBatch(userWalletId: UserWalletId, batch: List<Network>): List<NetworkResult> {
return coroutineScope {
batch.map { network ->
async(dispatchers.io) {
semaphore.withPermit {
processNetwork(userWalletId, network)
}
}
}.awaitAll()
}
}
private suspend fun handleBatchResults(
userWalletId: UserWalletId,
results: List<NetworkResult>,
tokenSyncStore: TokenSyncStore,
completedNetworks: Int,
totalNetworks: Int,
): Int {
var completed = completedNetworks
val progressFlow = getProgressFlow(userWalletId)
for (result in results) {
completed++
handleNetworkResult(result, tokenSyncStore)
progressFlow.value = TokenSyncProgress.InProgress(
completedNetworks = completed,
totalNetworks = totalNetworks,
)
}
return completed
}
private suspend fun handleNetworkResult(result: NetworkResult, tokenSyncStore: TokenSyncStore) {
when (result) {
is NetworkResult.Success -> {
if (result.responseTokens.isNotEmpty()) {
try {
tokenSyncStore.append(result.responseTokens)
} catch (e: Exception) {
TangemLogger.e("Failed to store discovered tokens for network: ${result.networkId}", e)
}
}
}
is NetworkResult.Error -> {
TangemLogger.e("Token sync failed for network: ${result.networkId}", result.cause)
}
}
}
private suspend fun processNetwork(userWalletId: UserWalletId, network: Network): NetworkResult {
return try {
val tokenBalances = fetchAndFilterTokenBalances(userWalletId, network)
if (tokenBalances.isEmpty()) {
return NetworkResult.Success(
networkId = network.backendId,
responseTokens = emptyList(),
)
}
val enrichedTokens = enrichTokensWithCatalog(tokenBalances, network)
val responseTokens = enrichedTokens
.filter { it.contractAddress != null }
.map { it.toResponseToken() }
NetworkResult.Success(
networkId = network.backendId,
responseTokens = responseTokens,
)
} catch (e: Exception) {
NetworkResult.Error(networkId = network.backendId, cause = e)
}
}
private suspend fun fetchAndFilterTokenBalances(userWalletId: UserWalletId, network: Network): List<TokenBalance> {
return withContext(dispatchers.io) {
walletManagersFacade.getTokenBalances(userWalletId, network)
.filter { it.amount > BigDecimal.ZERO }
}
}
private suspend fun enrichTokensWithCatalog(
tokenBalances: List<TokenBalance>,
network: Network,
): List<DiscoveredToken> = withContext(dispatchers.io) {
val tokensToEnrich = tokenBalances.filter { !it.isNativeToken }
val catalogMap = fetchCatalogInfo(
networkId = network.backendId,
contractAddresses = tokensToEnrich.mapNotNull(TokenBalance::contractAddress),
)
tokenBalances.mapNotNull { balance ->
if (balance.isNativeToken) return@mapNotNull null
val contractAddressLower = balance.contractAddress?.lowercase()
val coin = contractAddressLower?.let { catalogMap[it] } ?: return@mapNotNull null
val decimals = coin.networks
.find { it.contractAddress?.lowercase() == contractAddressLower }
?.decimalCount
?.toInt()
?: 0
DiscoveredToken(
contractAddress = balance.contractAddress,
symbol = coin.symbol,
name = coin.name,
decimals = decimals,
amount = balance.amount,
isNativeToken = false,
currencyId = coin.id,
networkId = network.backendId,
)
}
}
private suspend fun fetchCatalogInfo(
networkId: String,
contractAddresses: List<String>,
): Map<String, CoinsResponse.Coin> {
if (contractAddresses.isEmpty()) return emptyMap()
return try {
val response = tangemTechApi.getCoins(
networkId = networkId,
contractAddresses = contractAddresses.joinToString(","),
active = true,
).getOrThrow()
buildMap {
for (coin in response.coins) {
for (network in coin.networks) {
val address = network.contractAddress?.lowercase() ?: continue
put(address, coin)
}
}
}
} catch (e: Exception) {
TangemLogger.w(
"Failed to fetch catalog info for networkId=$networkId, addresses=${contractAddresses.size}",
e,
)
emptyMap()
}
}
private fun getSupportedNetworks(userWalletId: UserWalletId): List<Network> {
val userWallet = userWalletsListRepository.getSyncStrict(userWalletId)
return Blockchain.entries
.filter { !it.isTestnet() }
.filter { it !in excludedBlockchains }
.mapNotNull { blockchain ->
networkFactory.create(
blockchain = blockchain,
extraDerivationPath = null,
userWallet = userWallet,
)
}
}
private fun getProgressFlow(userWalletId: UserWalletId): MutableStateFlow<TokenSyncProgress> {
return progressStates.getOrPut(userWalletId.stringValue) {
MutableStateFlow(TokenSyncProgress.Idle)
}
}
private suspend fun setPendingFlag(userWalletId: UserWalletId, value: Boolean) {
appPreferencesStore.editData { prefs ->
prefs.setObjectMap(
key = PreferencesKeys.PENDING_DISCOVERY_SYNC_KEY,
value = prefs.getObjectMap<Boolean>(PreferencesKeys.PENDING_DISCOVERY_SYNC_KEY)
.plus(userWalletId.stringValue to value),
)
}
}
private fun DiscoveredToken.toResponseToken(): UserTokensResponse.Token {
return UserTokensResponse.Token(
id = currencyId,
networkId = networkId,
name = name,
symbol = symbol,
decimals = decimals,
contractAddress = contractAddress,
)
}
private data class DiscoveredToken(
val contractAddress: String?,
val symbol: String,
val name: String,
val decimals: Int,
val amount: BigDecimal,
val isNativeToken: Boolean,
val currencyId: String?,
val networkId: String,
)
private sealed class NetworkResult {
data class Success(
val networkId: String,
val responseTokens: List<UserTokensResponse.Token>,
) : NetworkResult()
data class Error(
val networkId: String,
val cause: Throwable,
) : NetworkResult()
}
companion object {
private const val MAX_CONCURRENT_REQUESTS = 3
}
}

View file

@ -78,6 +78,33 @@ class ManageCryptoCurrenciesUseCase(
add: List<CryptoCurrency> = emptyList(),
remove: List<CryptoCurrency> = emptyList(),
skipDerivationErrors: Boolean = true,
): Either<Throwable, Unit> = invokeInternal(
accountId = accountId,
add = add,
remove = remove,
skipDerivationErrors = skipDerivationErrors,
awaitTokensSyncFinished = false,
)
suspend fun invokeAndAwait(
accountId: AccountId,
add: List<CryptoCurrency> = emptyList(),
remove: List<CryptoCurrency> = emptyList(),
skipDerivationErrors: Boolean = true,
): Either<Throwable, Unit> = invokeInternal(
accountId = accountId,
add = add,
remove = remove,
skipDerivationErrors = skipDerivationErrors,
awaitTokensSyncFinished = true,
)
private suspend fun invokeInternal(
accountId: AccountId,
add: List<CryptoCurrency>,
remove: List<CryptoCurrency>,
skipDerivationErrors: Boolean,
awaitTokensSyncFinished: Boolean,
): Either<Throwable, Unit> = eitherOn(dispatchers.default) {
if (add.isEmpty() && remove.isEmpty()) {
TangemLogger.d("No currencies to add or remove, skipping")
@ -111,9 +138,11 @@ class ManageCryptoCurrenciesUseCase(
account = accountStatus.account.copy(cryptoCurrencies = modifiedCurrencyList.total),
)
parallelUpdatingScope.launch {
syncTokens(userWalletId, modifiedCurrencyList)
syncTokensAndLaunchUpdates(
userWalletId = userWalletId,
modifiedCurrencyList = modifiedCurrencyList,
awaitSync = awaitTokensSyncFinished,
) {
cryptoCurrencyBalanceFetcher(userWalletId = userWalletId, currencies = modifiedCurrencyList.added)
refreshExpress(userWalletId = userWalletId, currencies = modifiedCurrencyList.total)
clearMetadata(userWalletId = userWalletId, currencies = modifiedCurrencyList.removed)
@ -129,6 +158,7 @@ class ManageCryptoCurrenciesUseCase(
accountId: AccountId,
networkId: String,
contractAddress: String,
awaitTokensSyncFinished: Boolean = false,
): Either<Throwable, CryptoCurrency> = eitherOn(dispatchers.default) {
val userWalletId = accountId.userWalletId
@ -152,9 +182,11 @@ class ManageCryptoCurrenciesUseCase(
saveAccount(account = accountStatus.account.copy(cryptoCurrencies = modifiedCurrencyList.total))
parallelUpdatingScope.launch {
syncTokens(userWalletId, modifiedCurrencyList)
syncTokensAndLaunchUpdates(
userWalletId = userWalletId,
modifiedCurrencyList = modifiedCurrencyList,
awaitSync = awaitTokensSyncFinished,
) {
cryptoCurrencyBalanceFetcher(userWalletId = userWalletId, currencies = listOf(tokenToAdd))
refreshExpress(userWalletId = userWalletId, currencies = modifiedCurrencyList.total)
}
@ -285,6 +317,25 @@ class ManageCryptoCurrenciesUseCase(
.onFailure { TangemLogger.e("Failed to sync tokens for wallet $userWalletId", it) }
}
private suspend fun syncTokensAndLaunchUpdates(
userWalletId: UserWalletId,
modifiedCurrencyList: ModifiedCurrencyList,
awaitSync: Boolean,
updates: suspend () -> Unit,
) {
if (awaitSync) {
syncTokens(userWalletId, modifiedCurrencyList)
parallelUpdatingScope.launch {
updates()
}
} else {
parallelUpdatingScope.launch {
syncTokens(userWalletId, modifiedCurrencyList)
updates()
}
}
}
/**
* Creates wallet managers for the given [currencies] if they do not already exist.
* The method will generate addresses for new networks to ensure the stability of the "Push notifications" feature.

View file

@ -1,14 +0,0 @@
package com.tangem.domain.tokens.model.tokensync
import java.math.BigDecimal
data class DiscoveredToken(
val contractAddress: String?,
val symbol: String,
val name: String,
val decimals: Int,
val amount: BigDecimal,
val isNativeToken: Boolean,
val currencyId: String?,
val networkId: String,
)

View file

@ -1,22 +0,0 @@
package com.tangem.domain.tokens.model.tokensync
sealed class TokenSyncProgress {
data object Idle : TokenSyncProgress()
data class InProgress(
val completedNetworks: Int,
val totalNetworks: Int,
) : TokenSyncProgress() {
val progressPercent: Int
get() = if (totalNetworks > 0) {
completedNetworks * 100 / totalNetworks
} else {
0
}
}
data object Completed : TokenSyncProgress()
data class Error(val cause: Throwable) : TokenSyncProgress()
}

View file

@ -1,23 +0,0 @@
package com.tangem.domain.tokens.repository
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.tokens.model.tokensync.TokenSyncProgress
import kotlinx.coroutines.flow.Flow
interface TokenSyncRepository {
suspend fun runSync(userWalletId: UserWalletId)
suspend fun getPendingSyncWalletIds(): List<UserWalletId>
fun observeSyncProgress(userWalletId: UserWalletId): Flow<TokenSyncProgress>
fun acknowledgeCompletion(userWalletId: UserWalletId)
suspend fun clearPendingFlag(userWalletId: UserWalletId)
suspend fun getDiscoveredCurrencies(userWalletId: UserWalletId): List<CryptoCurrency>
suspend fun clearDiscoveredTokens(userWalletId: UserWalletId)
}

View file

@ -17,6 +17,4 @@ sealed class TokenSyncProgress {
}
data object Completed : TokenSyncProgress()
data class Error(val cause: Throwable) : TokenSyncProgress()
}

View file

@ -9,6 +9,8 @@ interface TokenSyncRepository {
suspend fun runSync(userWalletId: UserWalletId)
suspend fun completeSync(userWalletId: UserWalletId)
suspend fun getPendingSyncWalletIds(): List<UserWalletId>
fun observeSyncProgress(userWalletId: UserWalletId): Flow<TokenSyncProgress>

View file

@ -11,7 +11,7 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentHashMap
class SyncTokensUseCase(
class StartTokenSyncUseCase(
private val tokenSyncRepository: TokenSyncRepository,
private val manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase,
private val appCoroutineScope: AppCoroutineScope,
@ -25,6 +25,7 @@ class SyncTokensUseCase(
try {
tokenSyncRepository.runSync(userWalletId)
applyDiscoveredTokens(userWalletId)
tokenSyncRepository.completeSync(userWalletId)
} catch (e: Exception) {
TangemLogger.e("Token sync failed for wallet: $userWalletId", e)
} finally {
@ -61,7 +62,7 @@ class SyncTokensUseCase(
if (currencies.isEmpty()) return true
val accountId = AccountId.forMainCryptoPortfolio(userWalletId)
return manageCryptoCurrenciesUseCase(
return manageCryptoCurrenciesUseCase.invokeAndAwait(
accountId = accountId,
add = currencies,
).fold(

View file

@ -38,6 +38,7 @@ dependencies {
implementation(projects.domain.feedback)
implementation(projects.domain.feedback.models)
implementation(projects.domain.hotWallet)
implementation(projects.domain.tokensync)
/** Common */
implementation(projects.common.ui)

View file

@ -11,10 +11,12 @@ import com.tangem.core.ui.extensions.wrappedList
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.settings.CanUseBiometryUseCase
import com.tangem.domain.tokensync.usecase.StartTokenSyncUseCase
import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository
import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository.Attempts
import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository.Companion.MAX_FAST_FORWARD_ATTEMPTS
import com.tangem.domain.wallets.hot.HotWalletPasswordRequester
import com.tangem.features.hotwallet.HotWalletFeatureToggles
import com.tangem.features.hotwallet.accesscode.ACCESS_CODE_LENGTH
import com.tangem.features.hotwallet.accesscoderequest.entity.HotAccessCodeRequestUM
import com.tangem.features.hotwallet.impl.R
@ -30,12 +32,15 @@ import com.tangem.utils.logging.TangemLogger
import javax.inject.Inject
@ModelScoped
@Suppress("LongParameterList")
internal class HotAccessCodeRequestModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val hotAccessCodeAttemptsRepository: HotWalletAccessCodeAttemptsRepository,
private val userWalletsListRepository: UserWalletsListRepository,
private val canUseBiometryUseCase: CanUseBiometryUseCase,
private val analyticsEventHandler: AnalyticsEventHandler,
private val startTokenSyncUseCase: StartTokenSyncUseCase,
private val hotWalletFeatureToggles: HotWalletFeatureToggles,
) : Model() {
private val result = MutableStateFlow<HotWalletPasswordRequester.Result?>(null)
@ -214,6 +219,11 @@ internal class HotAccessCodeRequestModel @Inject constructor(
val currentRequest = currentRequest.value ?: return
val userWallet = userWalletsListRepository.userWalletsSync()
.firstOrNull { it is UserWallet.Hot && it.hotWalletId == currentRequest.hotWalletId } ?: return
if (hotWalletFeatureToggles.isTokenSyncEnabled) {
startTokenSyncUseCase.cancel(userWallet.walletId)
}
userWalletsListRepository.delete(listOf(userWallet.walletId))
dismiss()
}

View file

@ -17,8 +17,10 @@ import com.tangem.core.ui.message.bottomSheetMessage
import com.tangem.crypto.bip39.Mnemonic
import com.tangem.datasource.local.appsflyer.AppsFlyerStore
import com.tangem.domain.common.wallets.error.SaveWalletError
import com.tangem.domain.tokensync.usecase.StartTokenSyncUseCase
import com.tangem.domain.wallets.builder.HotUserWalletBuilder
import com.tangem.domain.wallets.usecase.SaveWalletUseCase
import com.tangem.features.hotwallet.HotWalletFeatureToggles
import com.tangem.features.hotwallet.MnemonicRepository
import com.tangem.features.hotwallet.addexistingwallet.im.port.AddExistingWalletImportComponent
import com.tangem.features.hotwallet.addexistingwallet.im.port.entity.AddExistingWalletImportUM
@ -40,6 +42,8 @@ internal class AddExistingWalletImportModel @Inject constructor(
private val tangemHotSdk: TangemHotSdk,
private val hotUserWalletBuilderFactory: HotUserWalletBuilder.Factory,
private val saveUserWalletUseCase: SaveWalletUseCase,
private val startTokenSyncUseCase: StartTokenSyncUseCase,
private val hotWalletFeatureToggles: HotWalletFeatureToggles,
@GlobalUiMessageSender private val uiMessageSender: UiMessageSender,
private val analyticsEventHandler: AnalyticsEventHandler,
private val appsFlyerStore: AppsFlyerStore,
@ -109,6 +113,11 @@ internal class AddExistingWalletImportModel @Inject constructor(
}
.onRight {
setImportProgress(false)
if (hotWalletFeatureToggles.isTokenSyncEnabled) {
startTokenSyncUseCase(userWallet.walletId)
}
analyticsEventHandler.send(
event = OnboardingAnalyticsEvent.Onboarding.Finished(
source = AnalyticsParam.ScreensSources.ImportWallet.value,

View file

@ -12,8 +12,10 @@ import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.EventMessageAction
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.domain.tokensync.usecase.StartTokenSyncUseCase
import com.tangem.domain.wallets.usecase.DeleteWalletUseCase
import com.tangem.features.hotwallet.ForgetWalletComponent
import com.tangem.features.hotwallet.HotWalletFeatureToggles
import com.tangem.features.hotwallet.forgetwallet.entity.ForgetWalletUM
import com.tangem.features.hotwallet.impl.R
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -23,6 +25,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
@Suppress("LongParameterList")
@ModelScoped
internal class ForgetWalletModel @Inject constructor(
paramsContainer: ParamsContainer,
@ -30,6 +33,8 @@ internal class ForgetWalletModel @Inject constructor(
private val router: Router,
private val deleteWalletUseCase: DeleteWalletUseCase,
private val uiMessageSender: UiMessageSender,
private val startTokenSyncUseCase: StartTokenSyncUseCase,
private val hotWalletFeatureToggles: HotWalletFeatureToggles,
) : Model() {
private val params = paramsContainer.require<ForgetWalletComponent.Params>()
@ -79,6 +84,10 @@ internal class ForgetWalletModel @Inject constructor(
private fun forgetWallet() {
modelScope.launch {
if (hotWalletFeatureToggles.isTokenSyncEnabled) {
startTokenSyncUseCase.cancel(params.userWalletId)
}
val hasUserWallets = deleteWalletUseCase(params.userWalletId)
.getOrElse { error ->
TangemLogger.e("Unable to delete wallet: $error")

View file

@ -9,7 +9,6 @@ enum class ManageTokensSource(val analyticsName: String) {
ONBOARDING(analyticsName = "Onboarding"),
SETTINGS(analyticsName = "Wallet Settings"),
ACCOUNT(analyticsName = "Account"),
TOKEN_SYNC_BANNER(analyticsName = "Token Sync Banner"),
SEND_VIA_SWAP(analyticsName = "SendViaSwap"),
}

View file

@ -50,6 +50,7 @@ dependencies {
implementation(projects.domain.settings)
implementation(projects.domain.notifications.models)
implementation(projects.domain.notifications)
implementation(projects.domain.tokensync)
/* AndroidX */
implementation(deps.androidx.fragment.ktx)

View file

@ -37,6 +37,7 @@ import com.tangem.domain.nft.EnableWalletNFTUseCase
import com.tangem.domain.nft.GetWalletNFTEnabledUseCase
import com.tangem.domain.notifications.repository.NotificationsRepository
import com.tangem.domain.settings.repositories.PermissionRepository
import com.tangem.domain.tokensync.usecase.StartTokenSyncUseCase
import com.tangem.domain.wallets.analytics.Settings
import com.tangem.domain.wallets.analytics.WalletSettingsAnalyticEvents
import com.tangem.domain.wallets.analytics.WalletSettingsAnalyticEvents.RecoveryPhraseScreenAction
@ -48,6 +49,7 @@ import com.tangem.feature.walletsettings.utils.AccountItemsDelegate
import com.tangem.feature.walletsettings.utils.AccountListSortingSaver
import com.tangem.feature.walletsettings.utils.ItemsBuilder
import com.tangem.feature.walletsettings.utils.WalletCardItemDelegate
import com.tangem.features.hotwallet.HotWalletFeatureToggles
import com.tangem.features.pushnotifications.api.analytics.PushNotificationAnalyticEvents
import com.tangem.hot.sdk.model.HotWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -88,6 +90,8 @@ internal class WalletSettingsModel @Inject constructor(
private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase,
private val singleAccountListSupplier: SingleAccountListSupplier,
private val accountListSortingSaver: AccountListSortingSaver,
private val startTokenSyncUseCase: StartTokenSyncUseCase,
private val hotWalletFeatureToggles: HotWalletFeatureToggles,
) : Model() {
val params: WalletSettingsComponent.Params = paramsContainer.require()
@ -249,6 +253,13 @@ internal class WalletSettingsModel @Inject constructor(
}
private fun forgetWallet() = modelScope.launch {
val userWallet = getUserWalletUseCase(params.userWalletId)
.getOrNull()
if (userWallet is UserWallet.Hot && hotWalletFeatureToggles.isTokenSyncEnabled) {
startTokenSyncUseCase.cancel(params.userWalletId)
}
val hasUserWallets = deleteWalletUseCase(params.userWalletId).getOrElse { error ->
TangemLogger.e("Unable to delete wallet: $error")

View file

@ -125,6 +125,7 @@ dependencies {
implementation(projects.domain.yieldSupply.models)
implementation(projects.domain.appTheme)
implementation(projects.domain.appTheme.models)
implementation(projects.domain.tokensync)
/** Feature Apis */
implementation(projects.features.details.api)

View file

@ -38,6 +38,8 @@ import com.tangem.domain.settings.*
import com.tangem.domain.tokens.RefreshMultiCurrencyWalletQuotesUseCase
import com.tangem.domain.walletconnect.WcPairService
import com.tangem.domain.walletconnect.model.WcPairRequest
import com.tangem.domain.tokensync.usecase.StartTokenSyncUseCase
import com.tangem.features.hotwallet.HotWalletFeatureToggles
import com.tangem.domain.wallets.usecase.*
import com.tangem.domain.yield.supply.usecase.YieldSupplyApyUpdateUseCase
import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents
@ -123,6 +125,8 @@ internal class WalletModel @Inject constructor(
private val paymentAccountStatusFetcher: PaymentAccountStatusFetcher,
private val tangemPayFeatureToggles: TangemPayFeatureToggles,
private val uiMessageSender: UiMessageSender,
private val hotWalletFeatureToggles: HotWalletFeatureToggles,
private val startTokenSyncUseCase: StartTokenSyncUseCase,
val screenLifecycleProvider: ScreenLifecycleProvider,
val innerWalletRouter: InnerWalletRouter,
) : Model() {
@ -155,6 +159,7 @@ internal class WalletModel @Inject constructor(
subscribeTangemPayOnWalletState()
subscribeToMainScreenQrScanning()
enableNotificationsIfNeeded()
applyPendingTokenSyncs()
clickIntents.initialize(innerWalletRouter, modelScope)
@ -819,6 +824,12 @@ internal class WalletModel @Inject constructor(
}
}
private fun applyPendingTokenSyncs() {
if (hotWalletFeatureToggles.isTokenSyncEnabled) {
startTokenSyncUseCase.applyPendingSyncs()
}
}
private fun enableNotificationsIfNeeded() {
modelScope.launch {
val isUserAllowToEnableNotifications = notificationsRepository.isUserAllowToSubscribeOnPushNotifications()

View file

@ -19,6 +19,7 @@ import com.tangem.domain.feedback.GetWalletMetaInfoUseCase
import com.tangem.domain.feedback.SendFeedbackEmailUseCase
import com.tangem.domain.feedback.models.FeedbackEmailType
import com.tangem.domain.hotwallet.CloseHotWalletUpgradeBannerUseCase
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
@ -39,6 +40,7 @@ import com.tangem.domain.tokens.model.analytics.PromoAnalyticsEvent
import com.tangem.domain.tokens.model.analytics.PromoAnalyticsEvent.Program
import com.tangem.domain.tokens.model.analytics.PromoAnalyticsEvent.PromotionBannerClicked
import com.tangem.domain.tokens.model.details.NavigationAction
import com.tangem.domain.tokensync.usecase.AcknowledgeTokenSyncCompletionUseCase
import com.tangem.domain.wallets.usecase.*
import com.tangem.feature.wallet.presentation.wallet.analytics.WalletScreenAnalyticsEvent
import com.tangem.feature.wallet.presentation.wallet.analytics.WalletScreenAnalyticsEvent.Basic
@ -94,6 +96,10 @@ internal interface WalletWarningsClickIntents {
fun onUpgradeHotWalletClick(userWalletId: UserWalletId)
fun onCloseUpgradeBannerClick(userWalletId: UserWalletId)
fun onDismissTokenSyncNotification(userWalletId: UserWalletId)
fun onTokenSyncManageClick(userWalletId: UserWalletId)
}
@Suppress("LargeClass", "LongParameterList", "TooManyFunctions")
@ -126,6 +132,7 @@ internal class WalletWarningsClickIntentsImplementor @Inject constructor(
private val uiMessageSender: UiMessageSender,
private val reviewManager: ReviewManager,
private val closeHotWalletUpgradeBannerUseCase: CloseHotWalletUpgradeBannerUseCase,
private val acknowledgeTokenSyncCompletionUseCase: AcknowledgeTokenSyncCompletionUseCase,
) : BaseWalletClickIntents(), WalletWarningsClickIntents {
override fun onAddBackupCardClick() {
@ -501,6 +508,17 @@ internal class WalletWarningsClickIntentsImplementor @Inject constructor(
}
}
override fun onDismissTokenSyncNotification(userWalletId: UserWalletId) {
acknowledgeTokenSyncCompletionUseCase(userWalletId)
}
override fun onTokenSyncManageClick(userWalletId: UserWalletId) {
acknowledgeTokenSyncCompletionUseCase(userWalletId)
router.openManageTokensScreen(
AccountId.forMainCryptoPortfolio(userWalletId),
)
}
private companion object {
const val VISA_PROMO_LINK = "https://tangem.com/en/cardwaitlist/?utm_source=tangem-app-banner" +
"&utm_medium=banner" +

View file

@ -20,7 +20,9 @@ internal object WalletPreviewDataLegacy {
balance = "8923,05312312312312312312331231231233432423423424234 $",
additionalInfo = WalletAdditionalInfo(
hideable = false,
content = TextReference.Str("3 cards • Seed phrase3 cards • Seed phrasephrasephrasephrase"),
content = WalletAdditionalInfo.Content.Text(
TextReference.Str("3 cards • Seed phrase3 cards • Seed phrasephrasephrasephrase"),
),
),
imageResId = R.drawable.ill_wallet2_cards3_120_106,
dropDownItems = persistentListOf(),

View file

@ -160,7 +160,7 @@ internal object WalletScreenPreviewDataLegacy {
title = "Note",
additionalInfo = WalletAdditionalInfo(
hideable = false,
content = TextReference.Str("Locked"),
content = WalletAdditionalInfo.Content.Text(TextReference.Str("Locked")),
),
imageResId = R.drawable.ill_note_btc_120_106,
dropDownItems = persistentListOf(),
@ -172,7 +172,7 @@ internal object WalletScreenPreviewDataLegacy {
title = "Wallet 1",
additionalInfo = WalletAdditionalInfo(
hideable = false,
content = TextReference.Str("Seed phrase"),
content = WalletAdditionalInfo.Content.Text(TextReference.Str("Seed phrase")),
),
imageResId = R.drawable.ill_wallet2_cards3_120_106,
cardCount = 3,

View file

@ -27,6 +27,9 @@ import com.tangem.domain.notifications.repository.NotificationsRepository
import com.tangem.domain.promo.ShouldShowPromoWalletUseCase
import com.tangem.domain.promo.models.PromoId
import com.tangem.domain.settings.IsReadyToShowRateAppUseCase
import com.tangem.domain.tokensync.model.TokenSyncProgress
import com.tangem.domain.tokensync.usecase.ObserveTokenSyncUseCase
import com.tangem.features.hotwallet.HotWalletFeatureToggles
import com.tangem.domain.wallets.usecase.IsNeedToBackupUseCase
import com.tangem.feature.wallet.child.wallet.model.WalletActivationBannerType
import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents
@ -43,6 +46,7 @@ import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import javax.inject.Inject
@ -61,6 +65,8 @@ internal class GetMultiWalletWarningsFactory @Inject constructor(
private val shouldShowUpgradeHotWalletBannerUseCase: ShouldShowUpgradeHotWalletBannerUseCase,
private val getUpgradeBannerClosureTimestampUseCase: GetUpgradeBannerClosureTimestampUseCase,
private val checkHotWalletUpgradeBannerUseCase: CheckHotWalletUpgradeBannerUseCase,
private val observeTokenSyncUseCase: ObserveTokenSyncUseCase,
private val hotWalletFeatureToggles: HotWalletFeatureToggles,
) {
@Suppress("UNCHECKED_CAST", "MagicNumber", "LongMethod", "CastNullableToNonNullableType")
@ -69,6 +75,12 @@ internal class GetMultiWalletWarningsFactory @Inject constructor(
val params = SingleAccountStatusListProducer.Params(userWallet.walletId)
val accountStatusListFlow = accountDependencies.singleAccountStatusListSupplier(params)
val tokenSyncProgressFlow = if (hotWalletFeatureToggles.isTokenSyncEnabled && userWallet is UserWallet.Hot) {
observeTokenSyncUseCase(userWallet.walletId).distinctUntilChanged()
} else {
flowOf(TokenSyncProgress.Idle)
}
return combine(
accountStatusListFlow,
isReadyToShowRateAppUseCase().distinctUntilChanged(),
@ -84,6 +96,7 @@ internal class GetMultiWalletWarningsFactory @Inject constructor(
.distinctUntilChanged(),
getUpgradeBannerClosureTimestampUseCase(userWallet.walletId)
.distinctUntilChanged(),
tokenSyncProgressFlow,
) { array -> array }
.map { array ->
val accountStatusList = array[0] as AccountStatusList
@ -95,6 +108,7 @@ internal class GetMultiWalletWarningsFactory @Inject constructor(
val shouldShowYieldPromo = array[6] as Boolean
val shouldShowUpgradeBanner = array[7] as Boolean
val closureTimestamp = array[8] as? Long
val tokenSyncProgress = array[9] as TokenSyncProgress
val flattenCurrencies = accountStatusList.flattenCurrencies()
val paymentAccountStatus = accountStatusList.accountStatuses
@ -139,6 +153,12 @@ internal class GetMultiWalletWarningsFactory @Inject constructor(
clickIntents = clickIntents,
)
addTokenSyncCompletedNotification(
userWallet = userWallet,
tokenSyncProgress = tokenSyncProgress,
clickIntents = clickIntents,
)
addPushReminderNotification(
clickIntents = clickIntents,
shouldShowPushReminderBanner = shouldShowEnablePushesReminderNotification &&
@ -384,6 +404,20 @@ internal class GetMultiWalletWarningsFactory @Inject constructor(
// }
// }
private fun MutableList<WalletNotification>.addTokenSyncCompletedNotification(
userWallet: UserWallet,
tokenSyncProgress: TokenSyncProgress,
clickIntents: WalletClickIntents,
) {
addIf(
element = WalletNotification.TokenSyncCompleted(
onCloseClick = { clickIntents.onDismissTokenSyncNotification(userWallet.walletId) },
onManageTokensClick = { clickIntents.onTokenSyncManageClick(userWallet.walletId) },
),
condition = tokenSyncProgress is TokenSyncProgress.Completed,
)
}
private fun MutableList<WalletNotification>.addRateTheAppNotification(
isReadyToShowRating: Boolean,
clickIntents: WalletClickIntents,

View file

@ -10,6 +10,7 @@ import com.tangem.domain.card.common.util.cardTypesResolver
import com.tangem.domain.card.common.util.getCardsCount
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.feature.wallet.impl.R
import com.tangem.feature.wallet.presentation.wallet.state.model.TokenSyncProgressUM
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletAdditionalInfo
import java.math.BigDecimal
@ -28,7 +29,11 @@ internal object WalletAdditionalInfoFactory {
* @param wallet current wallet
* @param currencyAmount amount of currency
*/
fun resolve(wallet: UserWallet, currencyAmount: BigDecimal? = null): WalletAdditionalInfo {
fun resolve(
wallet: UserWallet,
currencyAmount: BigDecimal? = null,
syncProgress: TokenSyncProgressUM = TokenSyncProgressUM.Idle,
): WalletAdditionalInfo {
return when (wallet) {
is UserWallet.Cold -> {
if (wallet.isMultiCurrency) {
@ -37,19 +42,26 @@ internal object WalletAdditionalInfoFactory {
wallet.resolveSingleCurrencyInfo(currencyAmount)
}
}
is UserWallet.Hot -> wallet.resolveAdditionalInfo()
is UserWallet.Hot -> wallet.resolveAdditionalInfo(syncProgress)
}
}
private fun UserWallet.Hot.resolveAdditionalInfo(): WalletAdditionalInfo {
private fun UserWallet.Hot.resolveAdditionalInfo(syncProgress: TokenSyncProgressUM): WalletAdditionalInfo {
val content = if (syncProgress is TokenSyncProgressUM.InProgress) {
WalletAdditionalInfo.Content.SyncProgress(syncProgress.progressPercent)
} else {
WalletAdditionalInfo.Content.Text(
TextReference.Res(R.string.hw_mobile_wallet) +
when {
isLocked -> DIVIDER + TextReference.Res(R.string.common_locked)
backedUp.not() -> DIVIDER + TextReference.Res(R.string.hw_backup_no_backup)
else -> TextReference.Str("")
},
)
}
return WalletAdditionalInfo(
hideable = false,
content = TextReference.Res(R.string.hw_mobile_wallet) +
when {
isLocked -> DIVIDER + TextReference.Res(R.string.common_locked)
backedUp.not() -> DIVIDER + TextReference.Res(R.string.hw_backup_no_backup)
else -> TextReference.Str("")
},
content = content,
isHotBackedUp = backedUp,
)
}
@ -58,9 +70,11 @@ internal object WalletAdditionalInfoFactory {
return if (isLocked) {
WalletAdditionalInfo(
hideable = false,
content = getBackupInfoWithDivider(
backupCardsCount = getCardsCount(),
) + TextReference.Res(R.string.common_locked),
content = WalletAdditionalInfo.Content.Text(
getBackupInfoWithDivider(
backupCardsCount = getCardsCount(),
) + TextReference.Res(R.string.common_locked),
),
)
} else {
val cardTypeResolver = scanResponse.cardTypesResolver
@ -76,8 +90,10 @@ internal object WalletAdditionalInfoFactory {
return if (isImported) {
WalletAdditionalInfo(
hideable = false,
content = getBackupInfoWithDivider(backupCardsCount = getCardsCount()) + TextReference.Res(
id = R.string.common_seed_phrase,
content = WalletAdditionalInfo.Content.Text(
getBackupInfoWithDivider(backupCardsCount = getCardsCount()) + TextReference.Res(
id = R.string.common_seed_phrase,
),
),
)
} else {
@ -94,7 +110,7 @@ internal object WalletAdditionalInfoFactory {
}
private fun getBackupInfo(backupCardsCount: Int?): WalletAdditionalInfo {
val content = if (backupCardsCount != null) {
val ref = if (backupCardsCount != null) {
getBackupInfoTextReference(count = backupCardsCount)
} else {
TextReference.EMPTY
@ -102,7 +118,7 @@ internal object WalletAdditionalInfoFactory {
return WalletAdditionalInfo(
hideable = false,
content = content,
content = WalletAdditionalInfo.Content.Text(ref),
)
}
@ -118,7 +134,7 @@ internal object WalletAdditionalInfoFactory {
return if (isLocked) {
WalletAdditionalInfo(
hideable = false,
content = TextReference.Res(R.string.common_locked),
content = WalletAdditionalInfo.Content.Text(TextReference.Res(R.string.common_locked)),
)
} else {
val blockchain = scanResponse.cardTypesResolver.getBlockchain()
@ -126,7 +142,7 @@ internal object WalletAdditionalInfoFactory {
WalletAdditionalInfo(
hideable = true,
content = TextReference.Str(value = amount.orEmpty()),
content = WalletAdditionalInfo.Content.Text(TextReference.Str(value = amount.orEmpty())),
)
}
}

View file

@ -3,6 +3,7 @@ package com.tangem.feature.wallet.presentation.wallet.loaders.implementors
import com.tangem.core.ui.DesignFeatureToggles
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.feature.wallet.presentation.wallet.subscribers.*
import com.tangem.features.hotwallet.HotWalletFeatureToggles
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@ -17,10 +18,12 @@ internal class MultiWalletContentLoader @AssistedInject constructor(
private val walletNotificationsSubscriberFactory: WalletNotificationsSubscriber.Factory,
private val multiWalletActionButtonsSubscriberFactory: MultiWalletActionButtonsSubscriber.Factory,
private val tangemPayMainSubscriberFactory: TangemPayMainSubscriber.Factory,
private val tokenSyncSubscriberFactory: TokenSyncSubscriber.Factory,
private val designFeatureToggles: DesignFeatureToggles,
private val hotWalletFeatureToggles: HotWalletFeatureToggles,
) : WalletContentLoader(id = userWallet.walletId) {
override fun create(): List<WalletSubscriber> = listOf(
override fun create(): List<WalletSubscriber> = listOfNotNull(
accountListSubscriberFactory.create(userWallet),
walletNFTListSubscriberFactory.create(userWallet),
checkWalletWithFundsSubscriberFactory.create(userWallet),
@ -31,6 +34,11 @@ internal class MultiWalletContentLoader @AssistedInject constructor(
},
multiWalletActionButtonsSubscriberFactory.create(userWallet),
tangemPayMainSubscriberFactory.create(userWallet),
if (hotWalletFeatureToggles.isTokenSyncEnabled && userWallet is UserWallet.Hot) {
tokenSyncSubscriberFactory.create(userWallet)
} else {
null
},
)
@AssistedFactory

View file

@ -0,0 +1,13 @@
package com.tangem.feature.wallet.presentation.wallet.state.model
import androidx.compose.runtime.Immutable
@Immutable
internal sealed class TokenSyncProgressUM {
data object Idle : TokenSyncProgressUM()
data class InProgress(val progressPercent: Int) : TokenSyncProgressUM()
data object Completed : TokenSyncProgressUM()
}

View file

@ -6,7 +6,12 @@ import com.tangem.core.ui.extensions.TextReference
@Immutable
data class WalletAdditionalInfo(
val hideable: Boolean,
val content: TextReference,
val content: Content,
val isHotBackedUp: Boolean = false,
val shouldShowProgress: Boolean = false,
)
) {
@Immutable
sealed interface Content {
data class Text(val text: TextReference) : Content
data class SyncProgress(val progressPercent: Int) : Content
}
}

View file

@ -85,7 +85,10 @@ internal sealed interface WalletCardState {
private companion object {
val defaultAdditionalInfo: WalletAdditionalInfo
get() = WalletAdditionalInfo(hideable = true, content = EMPTY_BALANCE_TEXT)
get() = WalletAdditionalInfo(
hideable = true,
content = WalletAdditionalInfo.Content.Text(EMPTY_BALANCE_TEXT),
)
}
}

View file

@ -27,6 +27,7 @@ internal sealed interface WalletState : WalletStateHolder {
abstract val tangemPayState: TangemPayState
abstract val tangemPayMainUM: TangemPayMainUM
abstract val isTangemPayRefactorEnabled: Boolean // TANGEM_PAY_ACCOUNTS_REFACTOR_ENABLED
abstract val tokenSyncProgressUM: TokenSyncProgressUM
data class Content(
override val pullToRefreshConfig: PullToRefreshConfig,
@ -40,6 +41,7 @@ internal sealed interface WalletState : WalletStateHolder {
override val tangemPayState: TangemPayState,
override val tangemPayMainUM: TangemPayMainUM,
override val isTangemPayRefactorEnabled: Boolean,
override val tokenSyncProgressUM: TokenSyncProgressUM = TokenSyncProgressUM.Idle,
) : MultiCurrency()
data class Locked(
@ -61,6 +63,7 @@ internal sealed interface WalletState : WalletStateHolder {
override val tangemPayState: TangemPayState = TangemPayState.Empty
override val tangemPayMainUM: TangemPayMainUM = TangemPayMainUM.Empty
override val isTangemPayRefactorEnabled: Boolean = false
override val tokenSyncProgressUM: TokenSyncProgressUM = TokenSyncProgressUM.Idle
}
}

View file

@ -61,7 +61,7 @@ internal class SetTokenListErrorTransformer(
walletsBalanceUM = walletUM.walletsBalanceUM.toLoadedState(),
tokensListUM = WalletTokensListUM.Empty(
onEmptyClick = {
clickIntents.onManageTokensClick(walletUM.walletsBalanceUM.id)
clickIntents.onTokenSyncManageClick(walletUM.walletsBalanceUM.id)
},
),
buttons = walletUM.disableButtons(),

View file

@ -122,7 +122,7 @@ internal class SetTokenListTransformer(
if (params !is TokenConverterParams.Account) {
return WalletTokensListUM.Empty(
onEmptyClick = {
clickIntents.onManageTokensClick(userWallet.walletId)
clickIntents.onTokenSyncManageClick(userWallet.walletId)
},
)
}

View file

@ -1,51 +1,34 @@
package com.tangem.feature.wallet.presentation.wallet.state.transformers
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.feature.wallet.impl.R
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletAdditionalInfo
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.feature.wallet.presentation.wallet.domain.WalletAdditionalInfoFactory
import com.tangem.feature.wallet.presentation.wallet.state.model.TokenSyncProgressUM
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletCardState
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletUM
internal class SetTokenSyncProgressTransformer(
userWalletId: UserWalletId,
private val progressPercent: Int,
) : WalletStateTransformer(userWalletId) {
private val userWallet: UserWallet,
private val progress: TokenSyncProgressUM,
) : WalletStateTransformer(userWallet.walletId) {
override fun transform(prevState: WalletState): WalletState {
return when (prevState) {
is WalletState.MultiCurrency.Content -> {
val updatedCardState = updateCardState(prevState.walletCardState)
prevState.copy(walletCardState = updatedCardState)
}
else -> {
prevState
}
is WalletState.MultiCurrency.Content -> prevState.copy(
walletCardState = updateCardState(prevState.walletCardState),
tokenSyncProgressUM = progress,
)
else -> prevState
}
}
override fun transform(walletUM: WalletUM): WalletUM {
return walletUM
}
override fun transform(walletUM: WalletUM): WalletUM = walletUM
private fun updateCardState(cardState: WalletCardState): WalletCardState {
val additionalInfo = WalletAdditionalInfo(
hideable = false,
content = resourceReference(
id = R.string.initial_wallet_sync_restore_progress,
formatArgs = wrappedList(progressPercent),
),
shouldShowProgress = true,
)
val additionalInfo = WalletAdditionalInfoFactory.resolve(wallet = userWallet, syncProgress = progress)
return when (cardState) {
is WalletCardState.Loading -> {
cardState.copy(additionalInfo = additionalInfo)
}
is WalletCardState.Content -> {
cardState.copy(additionalInfo = additionalInfo)
}
is WalletCardState.Loading -> cardState.copy(additionalInfo = additionalInfo)
is WalletCardState.Content -> cardState.copy(additionalInfo = additionalInfo)
else -> cardState
}
}

View file

@ -4,6 +4,7 @@ import com.tangem.domain.card.common.util.getCardsCount
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.feature.wallet.presentation.wallet.domain.WalletAdditionalInfoFactory
import com.tangem.feature.wallet.presentation.wallet.domain.WalletImageResolver
import com.tangem.feature.wallet.presentation.wallet.state.model.TokenSyncProgressUM
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletCardState
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletUM
@ -17,7 +18,9 @@ internal class UpdateWalletCardsCountTransformer(
override fun transform(prevState: WalletState): WalletState {
return when (prevState) {
is WalletState.MultiCurrency.Content -> {
prevState.copy(walletCardState = prevState.walletCardState.toUpdatedState())
prevState.copy(
walletCardState = prevState.walletCardState.toUpdatedState(prevState.tokenSyncProgressUM),
)
}
is WalletState.SingleCurrency.Content -> {
prevState.copy(walletCardState = prevState.walletCardState.toUpdatedState())
@ -35,10 +38,12 @@ internal class UpdateWalletCardsCountTransformer(
return walletUM // todo redesign main
}
private fun WalletCardState.toUpdatedState(): WalletCardState {
private fun WalletCardState.toUpdatedState(
syncProgress: TokenSyncProgressUM = TokenSyncProgressUM.Idle,
): WalletCardState {
return when (this) {
is WalletCardState.Content -> copy(
additionalInfo = WalletAdditionalInfoFactory.resolve(wallet = userWallet),
additionalInfo = WalletAdditionalInfoFactory.resolve(wallet = userWallet, syncProgress = syncProgress),
imageResId = walletImageResolver.resolve(userWallet = userWallet),
cardCount = (userWallet as? UserWallet.Cold)?.getCardsCount(),
)

View file

@ -0,0 +1,45 @@
package com.tangem.feature.wallet.presentation.wallet.subscribers
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.tokensync.model.TokenSyncProgress
import com.tangem.domain.tokensync.usecase.ObserveTokenSyncUseCase
import com.tangem.feature.wallet.presentation.wallet.state.WalletStateController
import com.tangem.feature.wallet.presentation.wallet.state.model.TokenSyncProgressUM
import com.tangem.feature.wallet.presentation.wallet.state.transformers.SetTokenSyncProgressTransformer
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.onEach
internal class TokenSyncSubscriber @AssistedInject constructor(
@Assisted private val userWallet: UserWallet,
private val stateController: WalletStateController,
private val observeTokenSyncUseCase: ObserveTokenSyncUseCase,
) : WalletSubscriber() {
override fun create(coroutineScope: CoroutineScope): Flow<*> {
return observeTokenSyncUseCase(userWallet.walletId)
.onEach { current -> handleProgress(userWallet, current) }
}
private fun handleProgress(userWallet: UserWallet, current: TokenSyncProgress) {
val progressUM = when (current) {
is TokenSyncProgress.InProgress -> TokenSyncProgressUM.InProgress(current.progressPercent)
is TokenSyncProgress.Completed -> TokenSyncProgressUM.Completed
is TokenSyncProgress.Idle -> TokenSyncProgressUM.Idle
}
stateController.update(
SetTokenSyncProgressTransformer(
userWallet = userWallet,
progress = progressUM,
),
)
}
@AssistedFactory
interface Factory {
fun create(userWallet: UserWallet): TokenSyncSubscriber
}
}

View file

@ -41,6 +41,9 @@ import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.conditional
import com.tangem.core.ui.extensions.orMaskWithStars
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.feature.wallet.impl.R
import com.tangem.core.ui.res.TangemDimens
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
@ -156,16 +159,10 @@ private fun CardContainer(state: WalletCardState, isBalanceHidden: Boolean, item
.padding(vertical = TangemTheme.dimens.spacing8),
)
val additionalText by remember(state.additionalInfo, isBalanceHidden) {
mutableStateOf(
state.additionalInfo?.content?.orMaskWithStars(
maskWithStars = state.additionalInfo?.hideable == true && isBalanceHidden,
),
)
}
AdditionalInfo(
text = additionalText,
showProgress = state.additionalInfo?.shouldShowProgress == true,
content = state.additionalInfo?.content,
hideable = state.additionalInfo?.hideable == true,
isBalanceHidden = isBalanceHidden,
modifier = Modifier.conditional(
state.imageResId == null,
) { fillMaxWidth() },
@ -300,28 +297,53 @@ private fun Modifier.nonContentBalanceSize(dimens: TangemDimens): Modifier {
}
@Composable
private fun AdditionalInfo(text: TextReference?, showProgress: Boolean, modifier: Modifier = Modifier) {
private fun AdditionalInfo(
content: WalletAdditionalInfo.Content?,
hideable: Boolean,
isBalanceHidden: Boolean,
modifier: Modifier = Modifier,
) {
AnimatedContent(
targetState = text,
targetState = content,
contentKey = { con ->
when (con) {
is WalletAdditionalInfo.Content.Text -> con
is WalletAdditionalInfo.Content.SyncProgress -> WalletAdditionalInfo.Content.SyncProgress::class
null -> null
}
},
label = "Update the additional text",
modifier = modifier,
transitionSpec = {
fadeIn(animationSpec = tween(durationMillis = 220, delayMillis = 90)) togetherWith
fadeOut(animationSpec = tween(durationMillis = 90))
},
) { animatedText ->
if (animatedText != null) {
) { animatedContent ->
if (animatedContent != null) {
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
AdditionalInfoText(text = animatedText)
if (showProgress) {
CircularProgressIndicator(
modifier = Modifier
.size(TangemTheme.dimens.size16),
color = TangemTheme.colors.icon.accent,
strokeWidth = TangemTheme.dimens.size2,
)
when (animatedContent) {
is WalletAdditionalInfo.Content.Text -> {
AdditionalInfoText(
text = animatedContent.text.orMaskWithStars(
maskWithStars = hideable && isBalanceHidden,
),
)
}
is WalletAdditionalInfo.Content.SyncProgress -> {
AdditionalInfoText(
text = resourceReference(
id = R.string.initial_wallet_sync_restore_progress,
formatArgs = wrappedList(animatedContent.progressPercent),
),
)
CircularProgressIndicator(
modifier = Modifier.size(TangemTheme.dimens.size16),
color = TangemTheme.colors.icon.accent,
strokeWidth = TangemTheme.dimens.size2,
)
}
}
}
} else {
@ -396,7 +418,7 @@ private class WalletCardStateProvider : CollectionPreviewParameterProvider<Walle
title = "Title",
additionalInfo = WalletAdditionalInfo(
hideable = false,
content = TextReference.Str("3 cards"),
content = WalletAdditionalInfo.Content.Text(TextReference.Str("3 cards")),
),
),
WalletPreviewDataLegacy.walletCardContentState.copy(