Updated on 2026-08-14

This commit is contained in:
Tangem 2024-07-05 16:25:03 +01:00
commit 712b8aacf6
17 changed files with 151 additions and 30 deletions

View file

@ -370,4 +370,16 @@ internal object TokensDomainModule {
networksRepository = networksRepository,
)
}
@Provides
@Singleton
fun provideRefreshMultiCurrencyWalletQuotesUseCase(
currenciesRepository: CurrenciesRepository,
quotesRepository: QuotesRepository,
): RefreshMultiCurrencyWalletQuotesUseCase {
return RefreshMultiCurrencyWalletQuotesUseCase(
currenciesRepository = currenciesRepository,
quotesRepository = quotesRepository,
)
}
}

View file

@ -370,6 +370,8 @@ internal class AddCustomTokenViewModel @Inject constructor(
viewModelScope.launch(dispatchers.main) {
runCatching(dispatchers.io) {
val tokenAddress = convertTokenAddress(selectedNetwork, address)
?: error("TokenAddress is invalid")
featureInteractor.findToken(address = tokenAddress, blockchain = selectedNetwork)
}
.onSuccess { token ->
@ -602,7 +604,8 @@ internal class AddCustomTokenViewModel @Inject constructor(
val contractAddress = convertTokenAddress(
blockchain = networkSelectorValue,
address = uiState.form.contractAddressInputField.value,
)
) ?: return false // invalid address can't be "already added"
return currentCryptoCurrencies
.filterIsInstance<CryptoCurrency.Token>()
.any { token ->
@ -859,7 +862,8 @@ internal class AddCustomTokenViewModel @Inject constructor(
val contractAddress = convertTokenAddress(
blockchain = blockchain,
address = foundToken?.network?.contractAddress ?: uiState.form.contractAddressInputField.value,
)
) ?: error("Contract address is invalid") // impossible to add a token with invalid address
CustomCurrency.CustomToken(
token = Token(
name = uiState.form.tokenNameInputField.value,
@ -897,7 +901,8 @@ internal class AddCustomTokenViewModel @Inject constructor(
}
}
private fun convertTokenAddress(blockchain: Blockchain, address: String): String {
/** Convert [address] to single address for specific [blockchain] or return null if invalid */
private fun convertTokenAddress(blockchain: Blockchain, address: String): String? {
return when (blockchain) {
Blockchain.Hedera, Blockchain.HederaTestnet -> hederaAddressConverter.convertToTokenId(address)
Blockchain.Cardano -> {

View file

@ -23,6 +23,9 @@ data class ExchangeProvider(
@Json(name = "privacyPolicy")
val privacyPolicy: String?,
@Json(name = "recommended")
val isRecommended: Boolean = false,
)
enum class ExchangeProviderType {

View file

@ -239,6 +239,7 @@
<string name="express_provider_min_amount">Доступно с %s</string>
<string name="express_provider_not_available">Недоступно для этой пары</string>
<string name="express_provider_permission_needed">Требуется разрешение</string>
<string name="express_provider_recommended">Рекомендовано</string>
<string name="express_terms_of_use">Условиями использования</string>
<string name="express_token_list_empty_search">Токены не найдены. Пожалуйста, попробуйте другой запрос</string>
<string name="express_transaction_id">ID: %s</string>

View file

@ -238,6 +238,7 @@
<string name="express_provider_min_amount">Available from %s</string>
<string name="express_provider_not_available">Unavailable for this pair</string>
<string name="express_provider_permission_needed">Permission Required</string>
<string name="express_provider_recommended">Recommended</string>
<string name="express_terms_of_use">Terms of Use</string>
<string name="express_token_list_empty_search">No tokens found. Please try another request</string>
<string name="express_transaction_id">ID: %s</string>

View file

@ -51,6 +51,19 @@ internal class DefaultQuotesRepository(
.flowOn(dispatchers.io)
}
override suspend fun fetchQuotes(currenciesIds: Set<CryptoCurrency.ID>) {
withContext(dispatchers.io) {
val selectedAppCurrency = requireNotNull(
value = appPreferencesStore.getObjectSyncOrNull<CurrenciesResponse.Currency>(
key = PreferencesKeys.SELECTED_APP_CURRENCY_KEY,
),
lazyMessage = { "Unable to get selected application currency to update quotes" },
)
fetchExpiredQuotes(currenciesIds, selectedAppCurrency.id, true)
}
}
override suspend fun getQuotesSync(currenciesIds: Set<CryptoCurrency.ID>, refresh: Boolean): Set<Quote> {
return withContext(dispatchers.io) {
val selectedAppCurrency = requireNotNull(

View file

@ -0,0 +1,53 @@
package com.tangem.domain.tokens
import arrow.core.Either
import arrow.core.getOrElse
import arrow.core.raise.catch
import arrow.core.raise.either
import com.tangem.domain.tokens.error.QuotesError
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.tokens.repository.QuotesRepository
import com.tangem.domain.wallets.models.UserWalletId
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
class RefreshMultiCurrencyWalletQuotesUseCase(
private val quotesRepository: QuotesRepository,
private val currenciesRepository: CurrenciesRepository,
) {
suspend operator fun invoke(userWalletId: UserWalletId): Either<QuotesError, Unit> {
return either {
val currencies = fetchCurrencies(userWalletId = userWalletId)
.getOrElse { raise(QuotesError.DataError(it)) }
coroutineScope {
val fetchQuotes = async {
fetchQuotes(
currenciesIds = currencies.mapTo(destination = hashSetOf(), transform = CryptoCurrency::id),
)
}
awaitAll(fetchQuotes)
}
}
}
private suspend fun fetchCurrencies(userWalletId: UserWalletId): Either<Throwable, List<CryptoCurrency>> {
return either {
catch(
block = { currenciesRepository.getMultiCurrencyWalletCurrenciesSync(userWalletId, false) },
catch = { raise(it) },
)
}
}
private suspend fun fetchQuotes(currenciesIds: Set<CryptoCurrency.ID>) {
catch(
block = { quotesRepository.fetchQuotes(currenciesIds) },
catch = { /* Ignore error */ },
)
}
}

View file

@ -0,0 +1,5 @@
package com.tangem.domain.tokens.error
sealed class QuotesError {
data class DataError(val cause: Throwable) : QuotesError()
}

View file

@ -31,4 +31,6 @@ interface QuotesRepository {
suspend fun getQuotesSync(currenciesIds: Set<CryptoCurrency.ID>, refresh: Boolean): Set<Quote>
suspend fun getQuoteSync(currencyId: CryptoCurrency.ID): Quote?
suspend fun fetchQuotes(currenciesIds: Set<CryptoCurrency.ID>)
}

View file

@ -25,4 +25,6 @@ internal class MockQuotesRepository(
return quotes.map { it.getOrElse { e -> throw e } }.first()
.first { it.rawCurrencyId == currencyId.rawCurrencyId }
}
override suspend fun fetchQuotes(currenciesIds: Set<CryptoCurrency.ID>) {}
}

View file

@ -45,6 +45,7 @@ class SwapPairInfoConverter : Converter<SwapPairsWithProviders, PairsWithProvide
imageLarge = exchangeProvider.imageLargeUrl,
termsOfUse = exchangeProvider.termsOfUse,
privacyPolicy = exchangeProvider.privacyPolicy,
isRecommended = exchangeProvider.isRecommended,
)
}
@ -61,6 +62,7 @@ class SwapPairInfoConverter : Converter<SwapPairsWithProviders, PairsWithProvide
imageLarge = additionalProvider.imageLargeUrl,
termsOfUse = additionalProvider.termsOfUse,
privacyPolicy = additionalProvider.privacyPolicy,
isRecommended = additionalProvider.isRecommended,
)
}

View file

@ -25,6 +25,7 @@ data class CryptoCurrencySwapInfo(
*
* @property providerId provider id
* @property rateTypes supported rate types
* @property isRecommended flag that indicates if this provider is recommended
*/
data class SwapProvider(
val providerId: String,
@ -34,6 +35,7 @@ data class SwapProvider(
val imageLarge: String,
val termsOfUse: String?,
val privacyPolicy: String?,
val isRecommended: Boolean,
)
enum class ExchangeProviderType(val providerName: String) {

View file

@ -44,9 +44,10 @@ sealed class ProviderState {
@Immutable
sealed class AdditionalBadge {
object BestTrade : AdditionalBadge()
object Empty : AdditionalBadge()
object PermissionRequired : AdditionalBadge()
data object BestTrade : AdditionalBadge()
data object Empty : AdditionalBadge()
data object PermissionRequired : AdditionalBadge()
data object Recommended : AdditionalBadge()
}
@Immutable
@ -54,6 +55,7 @@ sealed class ProviderState {
NONE, CLICK, SELECT
}
// Prefix will be disabled in 5.12 but mechanism is still implemented
@Immutable
enum class PrefixType {
NONE, PROVIDED_BY

View file

@ -87,6 +87,7 @@ fun ProviderItem(state: ProviderState, modifier: Modifier = Modifier, isSelected
}
}
// Be careful when will replace with InputRowBestRate, because RecommendedBadge was added
@Deprecated("Replace with InputRowBestRate")
@Suppress("LongMethod")
@Composable
@ -142,16 +143,12 @@ private fun ProviderContentState(
modifier = Modifier.padding(start = TangemTheme.dimens.spacing4),
)
}
val badgeModifier = Modifier.padding(start = TangemTheme.dimens.spacing4)
when (state.additionalBadge) {
ProviderState.AdditionalBadge.BestTrade -> BestTradeItem(
Modifier.padding(start = TangemTheme.dimens.spacing4),
)
ProviderState.AdditionalBadge.PermissionRequired -> PermissionBadgeItem(
Modifier.padding(start = TangemTheme.dimens.spacing4),
)
ProviderState.AdditionalBadge.Empty -> {
// no-op
}
ProviderState.AdditionalBadge.BestTrade -> BestTradeItem(badgeModifier)
ProviderState.AdditionalBadge.PermissionRequired -> PermissionBadgeItem(badgeModifier)
ProviderState.AdditionalBadge.Recommended -> RecommendedItem(badgeModifier)
ProviderState.AdditionalBadge.Empty -> Unit
}
}
Row(
@ -385,6 +382,23 @@ private fun PermissionBadgeItem(modifier: Modifier = Modifier) {
}
}
@Composable
private fun RecommendedItem(modifier: Modifier = Modifier) {
Box(
modifier = modifier.background(
color = TangemTheme.colors.icon.accent.copy(alpha = 0.1f),
shape = TangemTheme.shapes.roundedCornersLarge,
),
) {
Text(
text = stringResource(id = R.string.express_provider_recommended),
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.icon.accent,
modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing6),
)
}
}
// region Preview
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)

View file

@ -1541,7 +1541,9 @@ internal class StateBuilder(
val fromCurrencySymbol = fromTokenInfo.cryptoCurrencyStatus.currency.symbol
val toCurrencySymbol = toTokenInfo.cryptoCurrencyStatus.currency.symbol
val rateString = "1 $fromCurrencySymbol$rate $toCurrencySymbol"
val badge = if (isNeedBestRateBadge && isBestRate) {
val badge = if (isRecommended) {
ProviderState.AdditionalBadge.Recommended
} else if (isNeedBestRateBadge && isBestRate) {
ProviderState.AdditionalBadge.BestTrade
} else {
ProviderState.AdditionalBadge.Empty
@ -1555,7 +1557,7 @@ internal class StateBuilder(
additionalBadge = badge,
selectionType = selectionType,
percentLowerThenBest = PercentDifference.Empty,
namePrefix = ProviderState.PrefixType.PROVIDED_BY,
namePrefix = ProviderState.PrefixType.NONE,
onProviderClick = onProviderClick,
)
}
@ -1570,6 +1572,8 @@ internal class StateBuilder(
val rateString = toTokenInfo.tokenAmount.getFormattedCryptoAmount(toTokenInfo.cryptoCurrencyStatus.currency)
val additionalBadge = if (state.permissionState is PermissionDataState.PermissionReadyForRequest) {
ProviderState.AdditionalBadge.PermissionRequired
} else if (isRecommended) {
ProviderState.AdditionalBadge.Recommended
} else {
ProviderState.AdditionalBadge.Empty
}
@ -1619,11 +1623,7 @@ internal class StateBuilder(
subtitle = alertText,
additionalBadge = ProviderState.AdditionalBadge.Empty,
percentLowerThenBest = PercentDifference.Empty,
namePrefix = if (selectionType != ProviderState.SelectionType.SELECT) {
ProviderState.PrefixType.PROVIDED_BY
} else {
ProviderState.PrefixType.NONE
},
namePrefix = ProviderState.PrefixType.NONE,
onProviderClick = onProviderClick,
)
}

View file

@ -4,11 +4,13 @@ import androidx.compose.runtime.MutableState
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import arrow.core.getOrElse
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
import com.tangem.domain.settings.CanUseBiometryUseCase
import com.tangem.domain.settings.IsWalletsScrollPreviewEnabled
import com.tangem.domain.settings.ShouldShowSaveWalletScreenUseCase
import com.tangem.domain.tokens.RefreshMultiCurrencyWalletQuotesUseCase
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
@ -21,7 +23,6 @@ import com.tangem.feature.wallet.presentation.wallet.loaders.WalletScreenContent
import com.tangem.feature.wallet.presentation.wallet.state.WalletStateController
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletEvent
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletEvent.DemonstrateWalletsScrollPreview.Direction
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletPullToRefreshConfig
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletScreenState
import com.tangem.feature.wallet.presentation.wallet.state.transformers.*
import com.tangem.feature.wallet.presentation.wallet.state.utils.WalletEventSender
@ -61,6 +62,7 @@ internal class WalletViewModel @Inject constructor(
private val selectedWalletAnalyticsSender: SelectedWalletAnalyticsSender,
private val walletDeepLinksHandler: WalletDeepLinksHandler,
private val walletNameMigrationUseCase: WalletNameMigrationUseCase,
private val refreshMultiCurrencyWalletQuotesUseCase: RefreshMultiCurrencyWalletQuotesUseCase,
) : ViewModel() {
val uiState: StateFlow<WalletScreenState> = stateHolder.uiState
@ -178,7 +180,7 @@ internal class WalletViewModel @Inject constructor(
}
}
// We need to update the current wallet if the application was in the background for more than 10 seconds
// We need to update the current wallet quotes if the application was in the background for more than 10 seconds
// and then returned to the foreground
private fun subscribeToScreenBackgroundState() {
screenLifecycleProvider.isBackgroundState
@ -186,7 +188,7 @@ internal class WalletViewModel @Inject constructor(
refreshWalletJobHolder.cancel()
when {
isBackground -> needToRefreshTimer()
needToRefreshWallet && !isBackground -> triggerRefreshWallet()
needToRefreshWallet && !isBackground -> triggerRefreshWalletQuotes()
}
}
.launchIn(viewModelScope)
@ -199,13 +201,15 @@ internal class WalletViewModel @Inject constructor(
}.saveIn(refreshWalletJobHolder)
}
private fun triggerRefreshWallet() {
private fun triggerRefreshWalletQuotes() {
needToRefreshWallet = false
val state = stateHolder.uiState.value
val wallet = state.wallets.getOrNull(state.selectedWalletIndex) ?: return
wallet.pullToRefreshConfig.onRefresh.invoke(
WalletPullToRefreshConfig.ShowRefreshState(false),
)
viewModelScope.launch {
refreshMultiCurrencyWalletQuotesUseCase(wallet.walletCardState.id).getOrElse {
Timber.e("Failed to refreshMultiCurrencyWalletQuotesUseCase $it")
}
}.saveIn(refreshWalletJobHolder)
}
private suspend fun updateWallets(action: WalletsUpdateActionResolver.Action) {

View file

@ -87,7 +87,7 @@ markdown = "0.7.2"
# endregion Other libraries
# region Tangem
tangemBlockchainSdk = "release-app_5.12-694"
tangemBlockchainSdk = "release-app_5.12-698"
#tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds
tangemCardSdk = "release-app_5.12-369"
#tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^