Updated on 2026-08-14
This commit is contained in:
commit
712b8aacf6
17 changed files with 151 additions and 30 deletions
|
|
@ -370,4 +370,16 @@ internal object TokensDomainModule {
|
|||
networksRepository = networksRepository,
|
||||
)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideRefreshMultiCurrencyWalletQuotesUseCase(
|
||||
currenciesRepository: CurrenciesRepository,
|
||||
quotesRepository: QuotesRepository,
|
||||
): RefreshMultiCurrencyWalletQuotesUseCase {
|
||||
return RefreshMultiCurrencyWalletQuotesUseCase(
|
||||
currenciesRepository = currenciesRepository,
|
||||
quotesRepository = quotesRepository,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 -> {
|
||||
|
|
|
|||
|
|
@ -23,6 +23,9 @@ data class ExchangeProvider(
|
|||
|
||||
@Json(name = "privacyPolicy")
|
||||
val privacyPolicy: String?,
|
||||
|
||||
@Json(name = "recommended")
|
||||
val isRecommended: Boolean = false,
|
||||
)
|
||||
|
||||
enum class ExchangeProviderType {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 */ },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package com.tangem.domain.tokens.error
|
||||
|
||||
sealed class QuotesError {
|
||||
data class DataError(val cause: Throwable) : QuotesError()
|
||||
}
|
||||
|
|
@ -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>)
|
||||
}
|
||||
|
|
@ -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>) {}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 ^
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue