Updated on 2026-08-14
This commit is contained in:
parent
e7cd5f4c2e
commit
84390ebc91
6 changed files with 99 additions and 63 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue