Updated on 2026-08-14

This commit is contained in:
Tangem 2023-11-30 17:27:00 +03:00
commit 82c19782eb
11 changed files with 166 additions and 208 deletions

@ -1 +1 @@
Subproject commit 2a612cd92b1c78b99c917d0fe66342831f801e5e
Subproject commit 6ef8ce45d183905b5752e2d33c1d8bf2f4bcace6

View file

@ -14,16 +14,6 @@ interface SwapInteractor {
fun initDerivationPathAndNetwork(derivationPath: String?, network: Network)
/**
* On search token, locally search tokens in previously loaded list to swap
* searching in names and symbols
*
* @param networkId networkId for tokens
* @param searchQuery string query for search
* @return [FoundTokensStateExpress] that contains list of tokens matching condition query
*/
suspend fun searchTokens(networkId: String, searchQuery: String): FoundTokensStateExpress
/**
* Gives permission to swap, this starts scan card process
*
@ -88,10 +78,9 @@ interface SwapInteractor {
/**
* Returns token in wallet balance
*
* @param networkId
* @param token
*/
fun getTokenBalance(networkId: String, token: CryptoCurrency): SwapAmount
fun getTokenBalance(token: CryptoCurrencyStatus): SwapAmount
fun isAvailableToSwap(networkId: String): Boolean

View file

@ -19,7 +19,6 @@ import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import com.tangem.feature.swap.domain.cache.SwapDataCache
import com.tangem.feature.swap.domain.converters.SwapCurrencyConverter
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.data.AggregatedSwapDataModel
@ -44,7 +43,6 @@ internal class SwapInteractorImpl @Inject constructor(
private val transactionManager: TransactionManager,
private val userWalletManager: UserWalletManager,
private val repository: SwapRepository,
private val cache: SwapDataCache,
private val allowPermissionsHandler: AllowPermissionsHandler,
private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase,
private val getMultiCryptoCurrencyStatusUseCase: GetCryptoCurrencyStatusesSyncUseCase,
@ -166,25 +164,6 @@ internal class SwapInteractorImpl @Inject constructor(
this.network = network
}
@Deprecated("used in old swap mechanism")
override suspend fun searchTokens(networkId: String, searchQuery: String): FoundTokensStateExpress {
val searchQueryLowerCase = searchQuery.lowercase()
val tokensInWallet = cache.getInWalletTokens()
.filter {
it.token.name.lowercase().contains(searchQueryLowerCase) ||
it.token.symbol.lowercase().contains(searchQueryLowerCase)
}
val loadedTokens = cache.getLoadedTokens()
.filter {
it.token.name.lowercase().contains(searchQueryLowerCase) ||
it.token.symbol.lowercase().contains(searchQueryLowerCase)
}
return FoundTokensStateExpress(
tokensInWallet = tokensInWallet,
loadedTokens = loadedTokens,
)
}
@Deprecated("used in old swap mechanism")
override suspend fun givePermissionToSwap(networkId: String, permissionOptions: PermissionOptions): TxState {
val dataToSign = if (permissionOptions.approveType == SwapApproveType.UNLIMITED) {
@ -238,19 +217,14 @@ internal class SwapInteractorImpl @Inject constructor(
selectedFee: FeeType,
): Map<SwapProvider, SwapState> {
return providers.map { provider ->
syncWalletBalanceForTokens(networkId, listOf(fromToken.currency, toToken.currency))
val amountDecimal = toBigDecimalOrNull(amountToSwap)
if (amountDecimal == null || amountDecimal.signum() == 0) {
return providers.associateWith {
createEmptyAmountState(
networkId,
fromToken.currency,
toToken.currency,
)
createEmptyAmountState(fromToken, toToken)
}
}
val amount = SwapAmount(amountDecimal, getTokenDecimals(fromToken.currency))
val isBalanceWithoutFeeEnough = isBalanceEnough(networkId, fromToken.currency, amount, null)
val isBalanceWithoutFeeEnough = isBalanceEnough(fromToken, amount, null)
when (provider.type) {
ExchangeProviderType.DEX -> {
@ -302,7 +276,7 @@ internal class SwapInteractorImpl @Inject constructor(
val fromTokenAddress = getTokenAddress(fromToken.currency)
val isAllowedToSpend = quotes.dataModel?.allowanceContract?.let {
isAllowedToSpend(networkId, fromToken.currency, amount, it)
} ?: false
} ?: true
if (isAllowedToSpend && allowPermissionsHandler.isAddressAllowanceInProgress(fromTokenAddress)) {
allowPermissionsHandler.removeAddressFromProgress(fromTokenAddress)
@ -406,7 +380,7 @@ internal class SwapInteractorImpl @Inject constructor(
val amount = SwapAmount(amountDecimal, getTokenDecimals(fromToken.currency))
val feeByPriority = selectFeeByType(feeType = selectedFee, txFeeState = state.txFee)
val isBalanceIncludeFeeEnough =
isBalanceEnough(networkId, fromToken.currency, amount, feeByPriority)
isBalanceEnough(fromToken, amount, feeByPriority)
val isFeeEnough = checkFeeIsEnough(
fee = feeByPriority,
spendAmount = amount,
@ -543,28 +517,23 @@ internal class SwapInteractorImpl @Inject constructor(
value = fee.feeValue,
currencySymbol = fee.cryptoSymbol,
decimals = fee.decimals,
type = AmountType.Coin
type = AmountType.Coin,
)
return if (fee.gasLimit != 0) {
Fee.Ethereum(
amount = feeAmount,
gasLimit = fee.gasLimit.toBigInteger(),
gasPrice = (feeAmountValue / fee.gasLimit.toBigDecimal()).toBigInteger()
gasPrice = (feeAmountValue / fee.gasLimit.toBigDecimal()).toBigInteger(),
)
} else {
Fee.Common(feeAmount)
}
}
@Deprecated("used in old swap mechanism")
override fun getTokenBalance(networkId: String, token: CryptoCurrency): SwapAmount {
return cache.getBalanceForToken(
networkId = networkId,
derivationPath = derivationPath,
symbol = token.symbol,
) ?: SwapAmount(BigDecimal.ZERO, getTokenDecimals(token))
override fun getTokenBalance(token: CryptoCurrencyStatus): SwapAmount {
return SwapAmount(token.value.amount ?: BigDecimal.ZERO, getTokenDecimals(token.currency))
}
@Deprecated("used in old swap mechanism")
@ -611,17 +580,13 @@ internal class SwapInteractorImpl @Inject constructor(
)
}
private fun createEmptyAmountState(
networkId: String,
fromToken: CryptoCurrency,
toToken: CryptoCurrency,
): SwapState {
private fun createEmptyAmountState(fromToken: CryptoCurrencyStatus, toToken: CryptoCurrencyStatus): SwapState {
val appCurrency = userWalletManager.getUserAppCurrency()
val fromTokenBalance = cache.getBalanceForToken(networkId, derivationPath, fromToken.symbol)
val toTokenBalance = cache.getBalanceForToken(networkId, derivationPath, toToken.symbol)
val fromTokenBalance = getTokenBalance(fromToken)
val toTokenBalance = getTokenBalance(toToken)
return SwapState.EmptyAmountState(
fromTokenWalletBalance = fromTokenBalance?.let { amountFormatter.formatSwapAmountToUI(it, "") }.orEmpty(),
toTokenWalletBalance = toTokenBalance?.let { amountFormatter.formatSwapAmountToUI(it, "") }.orEmpty(),
fromTokenWalletBalance = amountFormatter.formatSwapAmountToUI(fromTokenBalance, ""),
toTokenWalletBalance = amountFormatter.formatSwapAmountToUI(toTokenBalance, ""),
zeroAmountEquivalent = BigDecimal.ZERO.toFiatString(
rateValue = BigDecimal.ONE,
fiatCurrencyName = appCurrency.symbol,
@ -707,7 +672,7 @@ internal class SwapInteractorImpl @Inject constructor(
ExchangeProviderType.DEX -> {
val state = updatePermissionState(
networkId = networkId,
fromToken = fromToken.currency,
fromTokenStatus = fromToken,
swapAmount = amount,
quotesLoadedState = swapState,
isAllowedToSpend = isAllowedToSpend,
@ -800,8 +765,7 @@ internal class SwapInteractorImpl @Inject constructor(
is ProxyFees.SingleFee -> feeData.proxyFeesToFeeState(networkId)
}
val feeByPriority = selectFeeByType(feeType = selectedFee, txFeeState = txFeeState)
val isBalanceIncludeFeeEnough =
isBalanceEnough(networkId, fromToken.currency, amount, feeByPriority)
val isBalanceIncludeFeeEnough = isBalanceEnough(fromToken, amount, feeByPriority)
val isFeeEnough = checkFeeIsEnough(
fee = feeByPriority,
spendAmount = amount,
@ -909,19 +873,20 @@ internal class SwapInteractorImpl @Inject constructor(
@Suppress("LongParameterList", "LongMethod")
private suspend fun updatePermissionState(
networkId: String,
fromToken: CryptoCurrency,
fromTokenStatus: CryptoCurrencyStatus,
swapAmount: SwapAmount,
quotesLoadedState: SwapState.QuotesLoadedState,
spenderAddress: String,
isAllowedToSpend: Boolean,
): SwapState.QuotesLoadedState {
val fromToken = fromTokenStatus.currency
if (isAllowedToSpend) {
return quotesLoadedState.copy(
permissionState = PermissionDataState.Empty,
)
}
// if token balance ZERO not show permission state to avoid user to spend money for fee
val isTokenZeroBalance = getTokenBalance(networkId, fromToken).value.signum() == 0
val isTokenZeroBalance = getTokenBalance(fromTokenStatus).value.signum() == 0
if (isTokenZeroBalance) {
return quotesLoadedState.copy(
permissionState = PermissionDataState.Empty,
@ -990,23 +955,6 @@ internal class SwapInteractorImpl @Inject constructor(
)
}
private suspend fun syncWalletBalanceForTokens(networkId: String, tokens: List<CryptoCurrency>) {
val tokensToSync = tokens.filter { cache.getBalanceForToken(networkId, derivationPath, it.symbol) == null }
if (tokensToSync.isNotEmpty()) {
val tokensBalance =
userWalletManager.getCurrentWalletTokensBalance(
networkId = networkId,
extraTokens = tokensToSync.map { swapCurrencyConverter.convert(it) },
derivationPath = derivationPath,
)
cache.cacheBalances(
networkId = networkId,
derivationPath = derivationPath,
balances = tokensBalance.mapValues { SwapAmount(it.value.value, it.value.decimals) },
)
}
}
private suspend fun ProxyFees.MultipleFees.proxyFeesToFeeState(networkId: String): TxFeeState {
val normalFeeValue = this.minFee.fee.value // in swap for normal use min fee
val normalFeeGas = this.minFee.gasLimit.toInt()
@ -1083,7 +1031,7 @@ internal class SwapInteractorImpl @Inject constructor(
val normalCryptoFee = amountFormatter.formatBigDecimalAmountToUI(
amount = feeNormal,
decimals = decimals
decimals = decimals,
)
val priorityCryptoFee = amountFormatter.formatBigDecimalAmountToUI(
amount = feePriority,
@ -1150,14 +1098,9 @@ internal class SwapInteractorImpl @Inject constructor(
}
}
private fun isBalanceEnough(
networkId: String,
fromToken: CryptoCurrency,
amount: SwapAmount,
fee: BigDecimal?,
): Boolean {
val tokenBalance = getTokenBalance(networkId, fromToken).value
return if (fromToken is CryptoCurrency.Token) {
private fun isBalanceEnough(fromToken: CryptoCurrencyStatus, amount: SwapAmount, fee: BigDecimal?): Boolean {
val tokenBalance = getTokenBalance(fromToken).value
return if (fromToken.currency is CryptoCurrency.Token) {
tokenBalance >= amount.value
} else {
tokenBalance > amount.value.plus(fee ?: BigDecimal.ZERO)

View file

@ -1,15 +0,0 @@
package com.tangem.feature.swap.domain.cache
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.ui.TokenWithBalanceExpress
interface SwapDataCache {
fun cacheInWalletTokens(tokens: List<TokenWithBalanceExpress>)
fun cacheLoadedTokens(tokens: List<TokenWithBalanceExpress>)
fun cacheBalances(networkId: String, derivationPath: String?, balances: Map<String, SwapAmount>)
fun getInWalletTokens(): List<TokenWithBalanceExpress>
fun getLoadedTokens(): List<TokenWithBalanceExpress>
fun getBalanceForToken(networkId: String, derivationPath: String?, symbol: String): SwapAmount?
}

View file

@ -1,41 +0,0 @@
package com.tangem.feature.swap.domain.cache
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.ui.TokenWithBalanceExpress
class SwapDataCacheImpl : SwapDataCache {
private val tokensBalances: MutableMap<String, Map<String, SwapAmount>> = mutableMapOf()
private val lastInWalletTokens = mutableListOf<TokenWithBalanceExpress>()
private val lastLoadedTokens = mutableListOf<TokenWithBalanceExpress>()
override fun cacheInWalletTokens(tokens: List<TokenWithBalanceExpress>) {
lastInWalletTokens.clear()
lastInWalletTokens.addAll(tokens)
}
override fun cacheLoadedTokens(tokens: List<TokenWithBalanceExpress>) {
lastLoadedTokens.clear()
lastLoadedTokens.addAll(tokens)
}
override fun getInWalletTokens(): List<TokenWithBalanceExpress> {
return lastInWalletTokens
}
override fun getLoadedTokens(): List<TokenWithBalanceExpress> {
return lastLoadedTokens
}
override fun getBalanceForToken(networkId: String, derivationPath: String?, symbol: String): SwapAmount? {
return tokensBalances[createKeyFrom(networkId, derivationPath)]?.get(symbol)
}
override fun cacheBalances(networkId: String, derivationPath: String?, balances: Map<String, SwapAmount>) {
tokensBalances[createKeyFrom(networkId, derivationPath)] = balances
}
private fun createKeyFrom(networkId: String, derivationPath: String?): String {
return "$networkId;$derivationPath"
}
}

View file

@ -13,7 +13,6 @@ import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.legacy.WalletsStateHolder
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import com.tangem.feature.swap.domain.*
import com.tangem.feature.swap.domain.cache.SwapDataCacheImpl
import com.tangem.lib.crypto.TransactionManager
import com.tangem.lib.crypto.UserWalletManager
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -45,7 +44,6 @@ class SwapDomainModule {
transactionManager = transactionManager,
userWalletManager = userWalletManager,
repository = swapRepository,
cache = SwapDataCacheImpl(),
allowPermissionsHandler = AllowPermissionsHandlerImpl(),
getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase,
getMultiCryptoCurrencyStatusUseCase = getCryptoCurrencyStatusUseCase,

View file

@ -2,7 +2,6 @@ package com.tangem.feature.swap.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material.Text
@ -24,16 +23,17 @@ import kotlinx.collections.immutable.toImmutableList
@Composable
fun ChooseProviderBottomSheet(config: TangemBottomSheetConfig) {
TangemBottomSheet(config) { content: ChooseProviderBottomSheetConfig ->
TangemBottomSheet(
config = config,
color = TangemTheme.colors.background.tertiary,
) { content: ChooseProviderBottomSheetConfig ->
ChooseProviderBottomSheetContent(content = content)
}
}
@Composable
private fun ChooseProviderBottomSheetContent(content: ChooseProviderBottomSheetConfig) {
Column(
modifier = Modifier.background(TangemTheme.colors.background.primary),
) {
Column {
Text(
text = stringResource(R.string.express_choose_providers_title),
style = TangemTheme.typography.subtitle1,
@ -60,7 +60,6 @@ private fun ChooseProviderBottomSheetContent(content: ChooseProviderBottomSheetC
shape = TangemTheme.shapes.roundedCornersXMedium,
)
.clip(shape = TangemTheme.shapes.roundedCornersXMedium),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
content.providers.forEach { provider ->
val isSelected = provider.id == content.selectedProviderId
@ -72,10 +71,7 @@ private fun ChooseProviderBottomSheetContent(content: ChooseProviderBottomSheetC
enabled = provider.onProviderClick != null,
onClick = { provider.onProviderClick?.invoke(provider.id) },
)
.padding(
horizontal = TangemTheme.dimens.spacing12,
vertical = TangemTheme.dimens.spacing12,
),
.padding(TangemTheme.dimens.spacing12),
)
}
}

View file

@ -146,7 +146,7 @@ private fun ProviderContentState(
)
if (state.percentLowerThenBest != null) {
Text(
text = "${state.percentLowerThenBest}%",
text = "-${state.percentLowerThenBest}%",
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.warning,
modifier = Modifier.padding(start = TangemTheme.dimens.spacing4),

View file

@ -194,12 +194,13 @@ internal class StateBuilder(
* @param fromToken token data to swap
* @return updated whole screen state
*/
@Suppress("LongMethod")
@Suppress("LongMethod", "LongParameterList")
fun createQuotesLoadedState(
uiStateHolder: SwapStateHolder,
quoteModel: SwapState.QuotesLoadedState,
fromToken: CryptoCurrency,
swapProvider: SwapProvider,
bestRatedProviderId: String,
selectedFeeType: FeeType,
): SwapStateHolder {
if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder
@ -288,6 +289,7 @@ internal class StateBuilder(
),
updateInProgress = false,
providerState = swapProvider.convertToContentClickableProviderState(
isBestRate = bestRatedProviderId == swapProvider.providerId,
fromTokenInfo = quoteModel.fromTokenInfo,
toTokenInfo = quoteModel.toTokenInfo,
selectionType = ProviderState.SelectionType.CLICK,
@ -310,6 +312,7 @@ internal class StateBuilder(
swapProvider = swapProvider,
fromToken = fromToken.cryptoCurrencyStatus.currency,
dataError = dataError,
onProviderClick = actions.onProviderClick,
selectionType = ProviderState.SelectionType.CLICK,
)
val receiveCardData = toToken?.let {
@ -358,6 +361,7 @@ internal class StateBuilder(
swapProvider: SwapProvider,
fromToken: CryptoCurrency,
dataError: DataError,
onProviderClick: (String) -> Unit,
selectionType: ProviderState.SelectionType,
): ProviderState {
return when (dataError) {
@ -368,7 +372,7 @@ internal class StateBuilder(
wrappedList(dataError.amount.getFormattedCryptoAmount(fromToken)),
),
selectionType = selectionType,
onProviderClick = actions.onProviderClick,
onProviderClick = onProviderClick,
)
}
else -> {
@ -712,13 +716,13 @@ internal class StateBuilder(
fun showSelectProviderBottomSheet(
uiState: SwapStateHolder,
selectedProviderId: String,
bestRatedProviderId: String,
pricesLowerBest: Map<SwapProvider, Float>,
providersStates: Map<SwapProvider, SwapState>,
unavailableProviders: List<SwapProvider>,
onDismiss: () -> Unit,
): SwapStateHolder {
val availableProvidersStates = providersStates.entries.mapNotNull {
it.convertToProviderState(bestRatedProviderId, actions.onProviderSelect)
it.convertToProviderBottomSheetState(pricesLowerBest, actions.onProviderSelect)
}
val unavailableProviderStates = unavailableProviders.map {
it.convertToUnavailableProviderState(
@ -818,23 +822,25 @@ internal class StateBuilder(
).toImmutableList()
}
private fun Map.Entry<SwapProvider, SwapState>.convertToProviderState(
bestRatedProviderId: String,
private fun Map.Entry<SwapProvider, SwapState>.convertToProviderBottomSheetState(
pricesLowerBest: Map<SwapProvider, Float>,
onProviderSelect: (String) -> Unit,
): ProviderState? {
val provider = this.key
return when (val state = this.value) {
is SwapState.EmptyAmountState -> null
is SwapState.QuotesLoadedState -> provider.convertToContentSelectableProviderState(
isBestRate = provider.providerId == bestRatedProviderId,
isBestRate = false, // not show best rate in bottom sheet
state = state,
onProviderClick = onProviderSelect,
pricesLowerBest = pricesLowerBest,
selectionType = ProviderState.SelectionType.SELECT,
)
is SwapState.SwapError -> getProviderStateForError(
swapProvider = provider,
fromToken = state.fromTokenInfo.cryptoCurrencyStatus.currency,
dataError = state.error,
onProviderClick = onProviderSelect,
selectionType = ProviderState.SelectionType.SELECT,
)
}
@ -893,6 +899,7 @@ internal class StateBuilder(
}
private fun SwapProvider.convertToContentClickableProviderState(
isBestRate: Boolean,
fromTokenInfo: TokenSwapInfo,
toTokenInfo: TokenSwapInfo,
selectionType: ProviderState.SelectionType,
@ -905,13 +912,18 @@ internal class StateBuilder(
val fromCurrencySymbol = fromTokenInfo.cryptoCurrencyStatus.currency.symbol
val toCurrencySymbol = toTokenInfo.cryptoCurrencyStatus.currency.symbol
val rateString = "1 $fromCurrencySymbol$rate $toCurrencySymbol"
val badge = if (isBestRate) {
ProviderState.AdditionalBadge.BestTrade
} else {
ProviderState.AdditionalBadge.Empty
}
return ProviderState.Content(
id = this.providerId,
name = this.name,
iconUrl = this.imageLarge,
type = this.type.toString(),
rate = rateString,
additionalBadge = ProviderState.AdditionalBadge.BestTrade,
additionalBadge = badge,
selectionType = selectionType,
percentLowerThenBest = null,
onProviderClick = onProviderClick,
@ -922,17 +934,11 @@ internal class StateBuilder(
isBestRate: Boolean,
state: SwapState.QuotesLoadedState,
selectionType: ProviderState.SelectionType,
pricesLowerBest: Map<SwapProvider, Float>,
onProviderClick: (String) -> Unit,
): ProviderState {
val fromTokenInfo = state.fromTokenInfo
val toTokenInfo = state.toTokenInfo
val rate = toTokenInfo.tokenAmount.value.calculateRate(
fromTokenInfo.tokenAmount.value,
toTokenInfo.cryptoCurrencyStatus.currency.decimals,
)
val fromCurrencySymbol = fromTokenInfo.cryptoCurrencyStatus.currency.symbol
val toCurrencySymbol = toTokenInfo.cryptoCurrencyStatus.currency.symbol
val rateString = "1 $fromCurrencySymbol$rate $toCurrencySymbol"
val rateString = toTokenInfo.tokenAmount.getFormattedCryptoAmount(toTokenInfo.cryptoCurrencyStatus.currency)
val additionalBadge = if (state.permissionState is PermissionDataState.PermissionReadyForRequest) {
ProviderState.AdditionalBadge.PermissionRequired
} else if (isBestRate) {
@ -948,7 +954,7 @@ internal class StateBuilder(
rate = rateString,
additionalBadge = additionalBadge,
selectionType = selectionType,
percentLowerThenBest = null,
percentLowerThenBest = pricesLowerBest[this],
onProviderClick = onProviderClick,
)
}

View file

@ -1,5 +1,6 @@
package com.tangem.feature.swap.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
@ -11,7 +12,10 @@ import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.common.Strings
import com.tangem.core.ui.components.*
@ -34,7 +38,12 @@ fun SwapSelectTokenScreen(state: SwapSelectTokenStateHolder, onBack: () -> Unit)
.systemBarsPadding()
.background(color = TangemTheme.colors.background.secondary),
content = { padding ->
ListOfTokens(state = state, Modifier.padding(padding))
val modifier = Modifier.padding(padding)
if (state.availableTokens.isEmpty() && state.unavailableTokens.isEmpty()) {
EmptyTokensList(modifier)
} else {
ListOfTokens(state = state, modifier = modifier)
}
},
topBar = {
ExpandableSearchView(
@ -49,6 +58,36 @@ fun SwapSelectTokenScreen(state: SwapSelectTokenStateHolder, onBack: () -> Unit)
)
}
@Composable
private fun EmptyTokensList(modifier: Modifier = Modifier) {
Box(
modifier = modifier
.background(TangemTheme.colors.background.secondary)
.fillMaxSize(),
) {
Column(modifier = Modifier.align(Alignment.Center)) {
Image(
modifier = Modifier
.size(TangemTheme.dimens.size64)
.align(Alignment.CenterHorizontally),
painter = painterResource(id = R.drawable.ic_no_token_44),
colorFilter = ColorFilter.tint(TangemTheme.colors.icon.inactive),
contentDescription = null,
)
Text(
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing16)
.padding(horizontal = TangemTheme.dimens.spacing30)
.align(Alignment.CenterHorizontally),
text = stringResource(id = R.string.exchange_tokens_empty_tokens),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
textAlign = TextAlign.Center,
)
}
}
}
@Composable
private fun ListOfTokens(state: SwapSelectTokenStateHolder, modifier: Modifier = Modifier) {
val screenBackgroundColor = TangemTheme.colors.background.secondary
@ -246,4 +285,12 @@ private fun TokenScreenPreview() {
onBack = {},
)
}
}
@Preview
@Composable
private fun EmptyTokensListPreview() {
TangemTheme(isDark = false) {
EmptyTokensList()
}
}

View file

@ -5,6 +5,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.*
import arrow.core.getOrElse
import arrow.core.mapNotNull
import com.tangem.common.Provider
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.ui.utils.InputNumberFormatter
@ -42,6 +43,8 @@ import java.util.Locale
import javax.inject.Inject
import kotlin.properties.Delegates
typealias SuccessLoadedSwapData = Map<SwapProvider, SwapState.QuotesLoadedState>
@Suppress("LargeClass", "LongParameterList")
@HiltViewModel
internal class SwapViewModel @Inject constructor(
@ -257,8 +260,12 @@ internal class SwapViewModel @Inject constructor(
}
},
onSuccess = { providersState ->
val (provider, state) = updateLoadedQuotes(providersState)
setupLoadedState(provider, state, fromToken)
if (providersState.isNotEmpty()) {
val (provider, state) = updateLoadedQuotes(providersState)
setupLoadedState(provider, state, fromToken)
} else {
Timber.e("Accidentally empty quotes list")
}
},
onError = {
Timber.e("Error when loading quotes: $it")
@ -271,11 +278,14 @@ internal class SwapViewModel @Inject constructor(
when (state) {
is SwapState.QuotesLoadedState -> {
fillLoadedDataState(state, state.permissionState, state.swapDataModel)
val loadedStates = dataState.lastLoadedSwapStates.getLastLoadedSuccessStates()
val bestRatedProviderId = findBestQuoteProvider(loadedStates)?.providerId ?: provider.providerId
uiState = stateBuilder.createQuotesLoadedState(
uiStateHolder = uiState,
quoteModel = state,
fromToken = fromToken.currency,
swapProvider = provider,
bestRatedProviderId = bestRatedProviderId,
selectedFeeType = dataState.selectedFee?.feeType ?: FeeType.NORMAL,
)
}
@ -309,15 +319,13 @@ internal class SwapViewModel @Inject constructor(
lastLoadedSwapStates = state,
)
selectedSwapProvider?.let {
return state.entries.first { it.key == selectedSwapProvider }.toPair()
return nonEmptyStates.entries.first { it.key == selectedSwapProvider }.toPair()
}
return state.entries.first().toPair()
}
private fun selectProvider(state: Map<SwapProvider, SwapState>): SwapProvider {
val stateSuccess = state
.filter { it.value is SwapState.QuotesLoadedState }
.mapValues { it.value as SwapState.QuotesLoadedState }
val stateSuccess = state.getLastLoadedSuccessStates()
return if (stateSuccess.isNotEmpty()) {
val currentSelected = dataState.selectedProvider
if (currentSelected != null && state.keys.contains(currentSelected)) {
@ -330,23 +338,6 @@ internal class SwapViewModel @Inject constructor(
}
}
private fun findBestQuoteProvider(state: Map<SwapProvider, SwapState.QuotesLoadedState>): SwapProvider? {
// finding best quotes
return state.mapValues {
if (!it.value.fromTokenInfo.amountFiat.isNullOrZero() &&
!it.value.toTokenInfo.amountFiat.isNullOrZero()
) {
it.value.fromTokenInfo.amountFiat.divide(
it.value.toTokenInfo.amountFiat,
it.value.toTokenInfo.cryptoCurrencyStatus.currency.decimals,
RoundingMode.HALF_UP,
)
} else {
BigDecimal.ZERO
}
}.minByOrNull { it.value }?.key
}
private fun fillLoadedDataState(
state: SwapState.QuotesLoadedState,
permissionState: PermissionDataState,
@ -601,7 +592,7 @@ internal class SwapViewModel @Inject constructor(
private fun onMaxAmountClicked() {
dataState.fromCryptoCurrency?.let {
val balance = swapInteractor.getTokenBalance(initialCryptoCurrency.network.id.value, it.currency)
val balance = swapInteractor.getTokenBalance(it)
onAmountChanged(balance.formatToUIRepresentation())
}
}
@ -714,15 +705,13 @@ internal class SwapViewModel @Inject constructor(
}
},
onProviderClick = { providerId ->
val states = dataState.lastLoadedSwapStates
.filter { it.value is SwapState.QuotesLoadedState }
.mapValues { it.value as SwapState.QuotesLoadedState }
val bestRatedProviderId = findBestQuoteProvider(states)?.providerId ?: providerId
val states = dataState.lastLoadedSwapStates.getLastLoadedSuccessStates()
val pricesLowerBest = getPricesLowerBest(states)
val unavailableProviders = getUnavailableProvidersFor(dataState.lastLoadedSwapStates)
uiState = stateBuilder.showSelectProviderBottomSheet(
uiState = uiState,
selectedProviderId = providerId,
bestRatedProviderId = bestRatedProviderId,
pricesLowerBest = pricesLowerBest,
unavailableProviders = unavailableProviders,
providersStates = dataState.lastLoadedSwapStates,
) { uiState = stateBuilder.dismissBottomSheet(uiState) }
@ -754,6 +743,45 @@ internal class SwapViewModel @Inject constructor(
return selectedProvider
}
private fun findBestQuoteProvider(state: SuccessLoadedSwapData): SwapProvider? {
// finding best quotes
return state.minByOrNull {
if (!it.value.fromTokenInfo.amountFiat.isNullOrZero() &&
!it.value.toTokenInfo.amountFiat.isNullOrZero()
) {
it.value.fromTokenInfo.amountFiat.divide(
it.value.toTokenInfo.amountFiat,
it.value.toTokenInfo.cryptoCurrencyStatus.currency.decimals,
RoundingMode.HALF_UP,
)
} else {
BigDecimal.ZERO
}
}?.key
}
private fun getPricesLowerBest(state: SuccessLoadedSwapData): Map<SwapProvider, Float> {
val rates = state.mapValues {
it.value.fromTokenInfo.amountFiat.divide(
it.value.toTokenInfo.amountFiat,
2,
RoundingMode.HALF_UP,
)
}
val bestRate = rates.minByOrNull { it.value } ?: return emptyMap()
return rates.mapNotNull {
if (it.key != bestRate.key) {
val percentDiff = bestRate.value
.divide(it.value, 2, RoundingMode.HALF_UP)
.multiply(BigDecimal(HUNDRED_PERCENT))
.toFloat()
HUNDRED_PERCENT - percentDiff
} else {
null
}
}
}
private fun createSelectedAppCurrencyFlow(): StateFlow<AppCurrency> {
return getSelectedAppCurrencyUseCase()
.map { maybeAppCurrency ->
@ -794,10 +822,17 @@ internal class SwapViewModel @Inject constructor(
return getAllProviders().filterNot { it in state }
}
private fun Map<SwapProvider, SwapState>.getLastLoadedSuccessStates(): SuccessLoadedSwapData {
return this
.filter { it.value is SwapState.QuotesLoadedState }
.mapValues { it.value as SwapState.QuotesLoadedState }
}
companion object {
private const val loggingTag = "SwapViewModel"
private const val INITIAL_AMOUNT = ""
private const val UPDATE_DELAY = 10000L
private const val DEBOUNCE_AMOUNT_DELAY = 1000L
private const val HUNDRED_PERCENT = 100
}
}