diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt index 31615ff878..5a67ab7227 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt @@ -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, diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/ChooseProviderBottomSheet.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/ChooseProviderBottomSheet.kt index f11e395923..30954893a7 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/ChooseProviderBottomSheet.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/ChooseProviderBottomSheet.kt @@ -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), ) } } diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/ProviderItem.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/ProviderItem.kt index 99595d6398..550fbfcad6 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/ProviderItem.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/ProviderItem.kt @@ -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), diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt index a13a37110c..0b18d866ad 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt @@ -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, providersStates: Map, unavailableProviders: List, 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.convertToProviderState( - bestRatedProviderId: String, + private fun Map.Entry.convertToProviderBottomSheetState( + pricesLowerBest: Map, 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, 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, ) } diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapSelectTokenScreen.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapSelectTokenScreen.kt index 9d90e20505..e1e6a80d73 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapSelectTokenScreen.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapSelectTokenScreen.kt @@ -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, ) } } diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/viewmodels/SwapViewModel.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/viewmodels/SwapViewModel.kt index d1dcdef1c0..9304f38196 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/viewmodels/SwapViewModel.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/viewmodels/SwapViewModel.kt @@ -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 + @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 { - 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? { - // 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 { + 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 { return getSelectedAppCurrencyUseCase() .map { maybeAppCurrency -> @@ -794,10 +822,17 @@ internal class SwapViewModel @Inject constructor( return getAllProviders().filterNot { it in state } } + private fun Map.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 } } \ No newline at end of file