Updated on 2026-08-14

This commit is contained in:
Tangem 2023-11-30 17:10:11 +03:00
parent e7cd5f4c2e
commit 84390ebc91
6 changed files with 99 additions and 63 deletions

View file

@ -302,7 +302,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)
@ -543,19 +543,18 @@ 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")
@ -1083,7 +1082,7 @@ internal class SwapInteractorImpl @Inject constructor(
val normalCryptoFee = amountFormatter.formatBigDecimalAmountToUI(
amount = feeNormal,
decimals = decimals
decimals = decimals,
)
val priorityCryptoFee = amountFormatter.formatBigDecimalAmountToUI(
amount = feePriority,

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

@ -63,7 +63,7 @@ private fun EmptyTokensList(modifier: Modifier = Modifier) {
Box(
modifier = modifier
.background(TangemTheme.colors.background.secondary)
.fillMaxSize()
.fillMaxSize(),
) {
Column(modifier = Modifier.align(Alignment.Center)) {
Image(
@ -82,7 +82,7 @@ private fun EmptyTokensList(modifier: Modifier = Modifier) {
text = stringResource(id = R.string.exchange_tokens_empty_tokens),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
textAlign = TextAlign.Center
textAlign = TextAlign.Center,
)
}
}

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,
@ -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
}
}