Updated on 2026-08-14

This commit is contained in:
Tangem 2023-11-28 20:42:03 +03:00
parent 03f003091e
commit 7f71ef182e
11 changed files with 260 additions and 61 deletions

View file

@ -4,7 +4,6 @@ import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.*
import com.tangem.feature.swap.domain.models.ui.*

View file

@ -1,7 +1,6 @@
package com.tangem.feature.swap.domain
import arrow.core.getOrElse
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.domain.tokens.AddCryptoCurrenciesUseCase
@ -384,7 +383,7 @@ internal class SwapInteractorImpl @Inject constructor(
amount = amount,
fee = fee,
providerId = swapProvider.providerId,
userWalletId = requireNotNull(getSelectedWallet()).walletId
userWalletId = requireNotNull(getSelectedWallet()).walletId,
)
}
ExchangeProviderType.DEX -> {
@ -485,7 +484,7 @@ internal class SwapInteractorImpl @Inject constructor(
amount: SwapAmount,
fee: TxFee,
providerId: String,
userWalletId: UserWalletId
userWalletId: UserWalletId,
): TxState {
val exchangeData = repository.getExchangeData(
fromContractAddress = currencyToSend.currency.getContractAddress(),
@ -511,37 +510,38 @@ internal class SwapInteractorImpl @Inject constructor(
val result = sendTransactionUseCase(
requireNotNull(txData),
userWallet = requireNotNull(getSelectedWallet()),
network = currencyToSend.currency.network
network = currencyToSend.currency.network,
)
return result.fold(ifLeft = {
when(it){
when (it) {
is SendTransactionError.NetworkError -> TxState.NetworkError
is SendTransactionError.DataError -> TxState.BlockchainError
SendTransactionError.DemoCardError -> TxState.UnknownError
else -> TxState.UnknownError
}
}, ifRight = {
TxState.TxSent(
fromAmount = amountFormatter.formatSwapAmountToUI(
amount,
currencyToSend.currency.symbol,
),
toAmount = amountFormatter.formatSwapAmountToUI(
exchangeData.dataModel.toTokenAmount,
currencyToGet.currency.symbol,
),
txAddress = userWalletManager.getLastTransactionHash(currencyToSend.currency.network.backendId, derivationPath) ?: "",
)
TxState.TxSent(
txAddress = userWalletManager.getLastTransactionHash(
networkId = currencyToSend.currency.network.backendId,
derivationPath = derivationPath
) ?: ""
)
})
TxState.TxSent(
fromAmount = amountFormatter.formatSwapAmountToUI(
amount,
currencyToSend.currency.symbol,
),
toAmount = amountFormatter.formatSwapAmountToUI(
exchangeData.dataModel.toTokenAmount,
currencyToGet.currency.symbol,
),
txAddress = userWalletManager.getLastTransactionHash(
currencyToSend.currency.network.backendId,
derivationPath,
) ?: "",
)
TxState.TxSent(
txAddress = userWalletManager.getLastTransactionHash(
networkId = currencyToSend.currency.network.backendId,
derivationPath = derivationPath,
) ?: "",
)
},)
}
@Deprecated("used in old swap mechanism")

View file

@ -114,7 +114,7 @@ class SwapDomainModule {
@SwapScope
@Provides
fun provideDemoCardUseCase() : IsDemoCardUseCase {
fun provideDemoCardUseCase(): IsDemoCardUseCase {
return IsDemoCardUseCase(config = DemoConfig())
}
@ -129,10 +129,9 @@ class SwapDomainModule {
return SendTransactionUseCase(
isDemoCardUseCase = isDemoCardUseCase,
cardSdkConfigRepository = cardSdkConfigRepository,
walletManagersFacade = walletManagersFacade
walletManagersFacade = walletManagersFacade,
)
}
}
@Qualifier

View file

@ -121,6 +121,8 @@ sealed interface SwapWarning {
* @property priceImpact in format = 10 (means 10%)
*/
data class HighPriceImpact(val priceImpact: Int, val notificationConfig: NotificationConfig) : SwapWarning
data class TooSmallAmountWarning(val notificationConfig: NotificationConfig) : SwapWarning
data class UnableToCoverFeeWarning(val notificationConfig: NotificationConfig) : SwapWarning
}
enum class GenericWarningType {

View file

@ -19,4 +19,5 @@ data class UiActions(
val onSelectFeeType: (TxFee) -> Unit,
val onProviderClick: (String) -> Unit,
val onProviderSelect: (String) -> Unit,
val onBuyClick: () -> Unit,
)

View file

@ -1,5 +1,7 @@
package com.tangem.feature.swap.models.states
import com.tangem.core.ui.extensions.TextReference
sealed class ProviderState {
abstract val onProviderClick: ((String) -> Unit)?
@ -32,7 +34,8 @@ sealed class ProviderState {
val name: String,
val type: String,
val iconUrl: String,
val alertText: String,
val alertText: TextReference,
val selectionType: SelectionType,
override val onProviderClick: ((String) -> Unit)? = null,
) : ProviderState()

View file

@ -14,6 +14,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.feature.swap.models.states.ChooseProviderBottomSheetConfig
import com.tangem.feature.swap.models.states.ProviderState
@ -99,7 +100,8 @@ private fun ChooseProviderBottomSheet_Preview() {
name = "1inch",
type = "DEX",
iconUrl = "",
alertText = "Unavailable",
selectionType = ProviderState.SelectionType.SELECT,
alertText = stringReference("Unavailable"),
),
)
TangemTheme(isDark = false) {

View file

@ -13,12 +13,15 @@ import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import coil.compose.SubcomposeAsyncImage
import coil.request.ImageRequest
import com.tangem.core.ui.R
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.components.SpacerH24
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.feature.swap.models.states.ProviderState
@ -64,6 +67,7 @@ fun ProviderItem(state: ProviderState, modifier: Modifier = Modifier, isSelected
ProviderUnavailableState(
state = state,
modifier = modifier,
isSelected = isSelected,
)
}
is ProviderState.Empty -> {
@ -127,31 +131,42 @@ private fun ProviderContentState(
}
}
Row(
modifier = Modifier.padding(top = TangemTheme.dimens.spacing8),
modifier = Modifier.padding(
top = TangemTheme.dimens.spacing8,
end = TangemTheme.dimens.spacing56,
),
) {
Text(
text = state.rate,
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.tertiary,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
if (state.percentLowerThenBest != null) {
Text(
text = "${state.percentLowerThenBest}%", // todo add to strings
text = "${state.percentLowerThenBest}%",
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.warning,
modifier = Modifier.padding(start = TangemTheme.dimens.spacing4),
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
}
}
}
}
ProviderChevron(state = state, isSelected = isSelected)
ProviderChevron(selectionType = state.selectionType, isSelected = isSelected)
}
}
@Composable
private fun ProviderUnavailableState(state: ProviderState.Unavailable, modifier: Modifier = Modifier) {
private fun ProviderUnavailableState(
state: ProviderState.Unavailable,
isSelected: Boolean,
modifier: Modifier = Modifier,
) {
Box(modifier = modifier.fillMaxWidth()) {
Row {
val (alpha, colorFilter) = GRAY_SCALE_ALPHA to GrayscaleColorFilter
@ -195,13 +210,15 @@ private fun ProviderUnavailableState(state: ProviderState.Unavailable, modifier:
)
}
Text(
text = state.alertText,
text = state.alertText.resolveReference(),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.tertiary,
modifier = Modifier.padding(top = TangemTheme.dimens.spacing8),
)
}
}
ProviderChevron(selectionType = state.selectionType, isSelected = isSelected)
}
}
@ -247,8 +264,8 @@ private fun ProviderLoadingState(modifier: Modifier = Modifier) {
}
@Composable
private fun BoxScope.ProviderChevron(state: ProviderState.Content, isSelected: Boolean) {
when (state.selectionType) {
private fun BoxScope.ProviderChevron(selectionType: ProviderState.SelectionType, isSelected: Boolean) {
when (selectionType) {
ProviderState.SelectionType.NONE -> {
/* no-op */
}
@ -396,7 +413,8 @@ private fun ProviderItem_Unavailable_Preview() {
name = "1inch",
type = "DEX",
iconUrl = "",
alertText = "Unavailable",
selectionType = ProviderState.SelectionType.SELECT,
alertText = stringReference("Unavailable"),
)
Column {
TangemTheme(isDark = false) {
@ -408,5 +426,11 @@ private fun ProviderItem_Unavailable_Preview() {
TangemTheme(isDark = true) {
ProviderItemBlock(state = state)
}
SpacerH24()
TangemTheme(isDark = true) {
ProviderItem(state = state, isSelected = true)
}
}
}

View file

@ -6,10 +6,7 @@ import com.tangem.common.Provider
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.currency.tokenicon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.model.CryptoCurrency
@ -125,7 +122,7 @@ internal class StateBuilder(
notificationConfig = NotificationConfig(
title = stringReference("No tokens"),
subtitle = stringReference("Swap tokens not available"),
iconResId = R.drawable.ic_alert_24,
iconResId = R.drawable.img_attention_20,
),
),
),
@ -213,6 +210,18 @@ internal class StateBuilder(
),
)
}
if (quoteModel.permissionState !is PermissionDataState.PermissionReadyForRequest &&
!quoteModel.preparedSwapConfigState.isFeeEnough
) {
warnings.add(
SwapWarning.UnableToCoverFeeWarning(
createUnableToCoverFeeNotificationConfig(
fromToken = fromToken,
onBuyClick = actions.onBuyClick,
),
),
)
}
if (!quoteModel.preparedSwapConfigState.isBalanceEnough) {
warnings.add(SwapWarning.InsufficientFunds)
}
@ -605,14 +614,23 @@ internal class StateBuilder(
fun showSelectProviderBottomSheet(
uiState: SwapStateHolder,
selectedProviderId: String,
bestRatedProviderId: String,
providersStates: Map<SwapProvider, SwapState>,
unavailableProviders: List<SwapProvider>,
onDismiss: () -> Unit,
): SwapStateHolder {
val availableProvidersStates = providersStates.entries.mapNotNull {
it.convertToProviderState(bestRatedProviderId, actions.onProviderSelect)
}
val unavailableProviderStates = unavailableProviders.map {
it.convertToUnavailableProviderState(
alertText = stringReference("Unavailable for pair"),
selectionType = ProviderState.SelectionType.NONE,
)
}
val config = ChooseProviderBottomSheetConfig(
selectedProviderId = selectedProviderId,
providers = providersStates.entries
.mapNotNull { it.convertToProviderState(actions.onProviderSelect) }
.toImmutableList(),
providers = (availableProvidersStates + unavailableProviderStates).toImmutableList(),
)
return uiState.copy(
bottomSheetConfig = TangemBottomSheetConfig(
@ -703,21 +721,27 @@ internal class StateBuilder(
}
private fun Map.Entry<SwapProvider, SwapState>.convertToProviderState(
bestRatedProviderId: String,
onProviderSelect: (String) -> Unit,
): ProviderState? {
val provider = this.key
return when (val state = this.value) {
is SwapState.EmptyAmountState -> null
is SwapState.QuotesLoadedState -> provider.convertToContentClickableProviderState(
fromTokenInfo = state.fromTokenInfo,
toTokenInfo = state.toTokenInfo,
is SwapState.QuotesLoadedState -> provider.convertToContentSelectableProviderState(
isBestRate = provider.providerId == bestRatedProviderId,
state = state,
onProviderClick = onProviderSelect,
selectionType = ProviderState.SelectionType.SELECT,
)
is SwapState.SwapError -> null
is SwapState.SwapError -> provider.convertToUnavailableProviderState(
alertText = stringReference("Available from"),
selectionType = ProviderState.SelectionType.NONE,
onProviderClick = onProviderSelect,
)
}
}
// region warnings
private fun createPermissionNotificationConfig(fromTokenSymbol: String): NotificationConfig {
return NotificationConfig(
title = resourceReference(R.string.swapping_permission_header),
@ -737,6 +761,25 @@ internal class StateBuilder(
)
}
private fun createUnableToCoverFeeNotificationConfig(
fromToken: CryptoCurrency,
onBuyClick: () -> Unit,
): NotificationConfig {
// todo add strings
return NotificationConfig(
title = stringReference("Unable to cover ${fromToken.name} fee"),
subtitle = stringReference(
"To make transaction you need to deposit some ${fromToken.name} ${fromToken.symbol}",
),
iconResId = fromToken.networkIconResId,
buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig(
text = stringReference("Buy ${fromToken.name}"),
onClick = onBuyClick,
),
)
}
// end region
private fun getShortAddressValue(fullAddress: String): String {
check(fullAddress.length > ADDRESS_MIN_LENGTH) { "Invalid address" }
val firstAddressPart = fullAddress.substring(startIndex = 0, endIndex = ADDRESS_FIRST_PART_LENGTH)
@ -774,6 +817,58 @@ internal class StateBuilder(
)
}
private fun SwapProvider.convertToContentSelectableProviderState(
isBestRate: Boolean,
state: SwapState.QuotesLoadedState,
selectionType: ProviderState.SelectionType,
onProviderClick: (String) -> Unit,
): ProviderState {
val fromTokenInfo = state.fromTokenInfo
val toTokenInfo = state.toTokenInfo
val rate = toTokenInfo.tokenAmount.value.divide(
fromTokenInfo.tokenAmount.value,
toTokenInfo.cryptoCurrencyStatus.currency.decimals,
RoundingMode.HALF_UP,
)
val fromCurrencySymbol = fromTokenInfo.cryptoCurrencyStatus.currency.symbol
val toCurrencySymbol = toTokenInfo.cryptoCurrencyStatus.currency.symbol
val rateString = "1 $fromCurrencySymbol$rate $toCurrencySymbol"
val additionalBadge = if (state.permissionState is PermissionDataState.PermissionReadyForRequest) {
ProviderState.AdditionalBadge.PermissionRequired
} else 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 = additionalBadge,
selectionType = selectionType,
percentLowerThenBest = null,
onProviderClick = onProviderClick,
)
}
private fun SwapProvider.convertToUnavailableProviderState(
alertText: TextReference,
selectionType: ProviderState.SelectionType,
onProviderClick: ((String) -> Unit)? = null,
): ProviderState {
return ProviderState.Unavailable(
id = this.providerId,
name = this.name,
iconUrl = this.imageLarge,
type = this.type.toString(),
selectionType = selectionType,
alertText = alertText,
onProviderClick = onProviderClick,
)
}
private fun CryptoCurrencyStatus.getFormattedAmount(): String {
val amount = value.amount ?: return UNKNOWN_AMOUNT_SIGN

View file

@ -256,6 +256,7 @@ private fun SwapWarnings(warnings: List<SwapWarning>) {
is SwapWarning.HighPriceImpact -> {
Notification(
config = warning.notificationConfig,
iconTint = TangemTheme.colors.icon.warning,
)
}
is SwapWarning.PermissionNeeded -> {
@ -282,6 +283,17 @@ private fun SwapWarnings(warnings: List<SwapWarning>) {
config = warning.notificationConfig,
)
}
is SwapWarning.TooSmallAmountWarning -> {
Notification(
config = warning.notificationConfig,
iconTint = TangemTheme.colors.icon.warning,
)
}
is SwapWarning.UnableToCoverFeeWarning -> {
Notification(
config = warning.notificationConfig,
)
}
else -> {}
}
SpacerH8()
@ -379,7 +391,7 @@ private val state = SwapStateHolder(
notificationConfig = NotificationConfig(
title = stringReference("No tokens"),
subtitle = stringReference("Swap tokens not available"),
iconResId = R.drawable.ic_alert_24,
iconResId = R.drawable.img_attention_20,
),
),
),

View file

@ -17,7 +17,6 @@ import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.feature.swap.analytics.SwapEvents
import com.tangem.feature.swap.domain.BlockchainInteractor
import com.tangem.feature.swap.domain.SwapInteractor
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.PermissionOptions
import com.tangem.feature.swap.domain.models.domain.SwapDataModel
import com.tangem.feature.swap.domain.models.domain.SwapProvider
@ -31,11 +30,14 @@ import com.tangem.feature.swap.ui.StateBuilder
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.Debouncer
import com.tangem.utils.coroutines.runCatching
import com.tangem.utils.isNullOrZero
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.math.BigDecimal
import java.math.RoundingMode
import java.text.DecimalFormat
import java.text.NumberFormat
import java.util.Locale
@ -287,29 +289,62 @@ internal class SwapViewModel @Inject constructor(
}
is SwapState.SwapError -> {
Timber.e("SwapError when loading quotes ${state.error}")
// todo handle when change token and error
uiState = stateBuilder.mapError(uiState, state.error) { startLoadingQuotesFromLastState() }
}
}
}
private fun updateLoadedQuotes(state: Map<SwapProvider, SwapState>): Pair<SwapProvider, SwapState> {
val selectedSwapProvider = selectProvider(state)
val nonEmptyStates = state.filter { it.value !is SwapState.EmptyAmountState }
val selectedSwapProvider = if (nonEmptyStates.isNotEmpty()) {
selectProvider(state)
} else {
null
}
dataState = dataState.copy(
selectedProvider = selectedSwapProvider,
lastLoadedSwapStates = state,
)
return state.entries.first { it.key == selectedSwapProvider }.toPair()
selectedSwapProvider?.let {
return state.entries.first { it.key == selectedSwapProvider }.toPair()
}
return state.entries.first().toPair()
}
private fun selectProvider(state: Map<SwapProvider, SwapState>): SwapProvider {
val currentSelected = dataState.selectedProvider
return if (currentSelected != null && state.keys.contains(currentSelected)) {
currentSelected
val stateSuccess = state
.filter { it.value is SwapState.QuotesLoadedState }
.mapValues { it.value as SwapState.QuotesLoadedState }
return if (stateSuccess.isNotEmpty()) {
val currentSelected = dataState.selectedProvider
if (currentSelected != null && state.keys.contains(currentSelected)) {
currentSelected
} else {
findBestQuoteProvider(stateSuccess) ?: stateSuccess.keys.first()
}
} else {
state.keys.first()
}
}
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,
@ -478,7 +513,6 @@ internal class SwapViewModel @Inject constructor(
private fun onTokenSelect(id: String) {
val tokens = dataState.tokensDataState ?: return
val foundToken = if (isOrderReversed) {
tokens.fromGroup.available.firstOrNull {
it.currencyStatus.currency.id.value == id
@ -505,6 +539,7 @@ internal class SwapViewModel @Inject constructor(
dataState = dataState.copy(
fromCryptoCurrency = fromToken,
toCryptoCurrency = toToken,
selectedProvider = null,
)
startLoadingQuotes(
fromToken = fromToken,
@ -675,10 +710,17 @@ internal class SwapViewModel @Inject constructor(
setupLoadedState(selectedProvider, updatedState, fromToken)
}
},
onProviderClick = {
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 unavailableProviders = getUnavailableProvidersFor(dataState.lastLoadedSwapStates)
uiState = stateBuilder.showSelectProviderBottomSheet(
uiState = uiState,
selectedProviderId = it,
selectedProviderId = providerId,
bestRatedProviderId = bestRatedProviderId,
unavailableProviders = unavailableProviders,
providersStates = dataState.lastLoadedSwapStates,
) { uiState = stateBuilder.dismissBottomSheet(uiState) }
},
@ -695,6 +737,7 @@ internal class SwapViewModel @Inject constructor(
)
}
},
onBuyClick = {},
)
}
@ -736,6 +779,25 @@ internal class SwapViewModel @Inject constructor(
return groupToFind.available.find { idToFind == it.currencyStatus.currency.id.value }?.providers ?: emptyList()
}
private fun getAllProviders(): List<SwapProvider> {
dataState.tokensDataState?.let { data ->
val allProviders =
data.fromGroup.available.flatMap { it.providers } + data.toGroup.available.flatMap { it.providers }
return allProviders.distinct()
} ?: return emptyList()
}
private fun getUnavailableProvidersFor(state: Map<SwapProvider, SwapState>): List<SwapProvider> {
return getAllProviders()
.mapNotNull {
if (state.containsKey(it)) {
null
} else {
it
}
}
}
companion object {
private const val loggingTag = "SwapViewModel"
private const val INITIAL_AMOUNT = ""