Updated on 2026-08-14
This commit is contained in:
commit
9942763843
55 changed files with 1680 additions and 530 deletions
|
|
@ -326,10 +326,11 @@ internal class ChildFactory @Inject constructor(
|
|||
createComponentChild(
|
||||
context = context,
|
||||
params = SwapComponent.Params(
|
||||
cryptoCurrency = route.cryptoCurrency,
|
||||
fromCryptoCurrency = route.fromCryptoCurrency,
|
||||
toCryptoCurrency = route.toCryptoCurrency,
|
||||
userWalletId = route.userWalletId,
|
||||
screenSource = route.screenSource,
|
||||
currencyPosition = when (route.currencyPosition) {
|
||||
fromCurrencyPosition = when (route.fromCurrencyPosition) {
|
||||
AppRoute.Swap.CurrencyPosition.FROM -> SwapComponent.Params.CurrencyPosition.FROM
|
||||
AppRoute.Swap.CurrencyPosition.TO -> SwapComponent.Params.CurrencyPosition.TO
|
||||
AppRoute.Swap.CurrencyPosition.ANY -> SwapComponent.Params.CurrencyPosition.ANY
|
||||
|
|
|
|||
|
|
@ -205,13 +205,14 @@ sealed class AppRoute(val path: String) : Route {
|
|||
@Serializable
|
||||
data class Swap(
|
||||
val userWalletId: UserWalletId,
|
||||
val cryptoCurrency: CryptoCurrency? = null,
|
||||
val fromCryptoCurrency: CryptoCurrency? = null,
|
||||
val screenSource: String,
|
||||
val currencyPosition: CurrencyPosition = CurrencyPosition.ANY,
|
||||
val fromCurrencyPosition: CurrencyPosition = CurrencyPosition.ANY,
|
||||
val tangemPayInput: TangemPayInput? = null,
|
||||
val toCryptoCurrency: CryptoCurrency? = null,
|
||||
) : AppRoute(
|
||||
path = "/swap" +
|
||||
"/${cryptoCurrency?.id?.value}" +
|
||||
"/${fromCryptoCurrency?.id?.value}" +
|
||||
"/${userWalletId.stringValue}",
|
||||
) {
|
||||
@Serializable
|
||||
|
|
|
|||
|
|
@ -136,7 +136,7 @@ class TokenActionsHandler @AssistedInject constructor(
|
|||
private fun onExchangeClick(cryptoCurrencyData: CryptoCurrencyData) {
|
||||
router.push(
|
||||
AppRoute.Swap(
|
||||
cryptoCurrency = cryptoCurrencyData.status.currency,
|
||||
fromCryptoCurrency = cryptoCurrencyData.status.currency,
|
||||
userWalletId = cryptoCurrencyData.userWallet.walletId,
|
||||
screenSource = AnalyticsParam.ScreensSources.Markets.value,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -216,12 +216,14 @@ private val PreviewExpressTransactionState: ExpressTransactionStateUM = object :
|
|||
toAmountSymbol = "BTC",
|
||||
toCurrencyIcon = CurrencyIconState.Loading,
|
||||
toAddress = "0x",
|
||||
toAmountDecimals = 2,
|
||||
fromAmount = stringReference("100 SOL"),
|
||||
fromAmountValue = "100".toBigDecimal(),
|
||||
fromFiatAmount = null,
|
||||
fromAmountSymbol = "SOL",
|
||||
fromCurrencyIcon = CurrencyIconState.Loading,
|
||||
fromAddress = "0x",
|
||||
fromAmountDecimals = 2,
|
||||
)
|
||||
}
|
||||
// endregion
|
||||
|
|
@ -40,11 +40,13 @@ data class ExpressTransactionStateInfoUM(
|
|||
val toAmount: TextReference,
|
||||
val toAmountValue: BigDecimal,
|
||||
val toFiatAmount: TextReference?,
|
||||
val toAmountDecimals: Int,
|
||||
val toAmountSymbol: String,
|
||||
val toCurrencyIcon: CurrencyIconState,
|
||||
val toAddress: String,
|
||||
val fromAmount: TextReference,
|
||||
val fromAmountValue: BigDecimal,
|
||||
val fromAmountDecimals: Int,
|
||||
val fromFiatAmount: TextReference?,
|
||||
val fromAmountSymbol: String,
|
||||
val fromCurrencyIcon: CurrencyIconState,
|
||||
|
|
|
|||
|
|
@ -150,5 +150,9 @@
|
|||
{
|
||||
"name": "AND_15235_VISA_MULTIPLE_CARDS",
|
||||
"version": "5.40"
|
||||
},
|
||||
{
|
||||
"name": "AND_15715_SWAP_BEST_DEX_RATE_ENABLED",
|
||||
"version": "undefined"
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -34,4 +34,5 @@ dependencies {
|
|||
testImplementation(deps.test.junit5)
|
||||
testImplementation(deps.test.truth)
|
||||
testImplementation(deps.test.mockk)
|
||||
testImplementation(deps.test.coroutine)
|
||||
}
|
||||
|
|
@ -38,11 +38,14 @@ fun SwapCurrencies.getGroupWithDirection(swapDirection: SwapDirection): SwapCurr
|
|||
* @param available list of available currencies to swap
|
||||
* @param available list of unavailable currencies to swap
|
||||
* @param isAfterSearch flag indicates whether user searched token
|
||||
* @param availableForSwap currencies that are unavailable for the current flow (e.g. Send with Swap), but
|
||||
* available in the regular Swap flow. Empty by default for backward compatibility (regular Swap doesn't fill it).
|
||||
*/
|
||||
data class SwapCurrenciesGroup(
|
||||
val available: List<SwapCryptoCurrency>,
|
||||
val unavailable: List<SwapCryptoCurrency>,
|
||||
val isAfterSearch: Boolean,
|
||||
val availableForSwap: List<SwapCryptoCurrency> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -24,11 +24,13 @@ class GetSwapSupportedPairsUseCase(
|
|||
filterProviderTypes: List<ExpressProviderType>,
|
||||
swapTxType: SwapTxType,
|
||||
) = Either.catch {
|
||||
// Request all provider types so the use case can tell apart currencies available for the current flow
|
||||
// from those available only in the regular Swap flow. The current-flow filter is applied below.
|
||||
val pairs = swapRepositoryV2.getSupportedPairs(
|
||||
userWallet = userWallet,
|
||||
initialCurrency = initialCurrency,
|
||||
cryptoCurrencyList = cryptoCurrencyList,
|
||||
filterProviderTypes = filterProviderTypes,
|
||||
filterProviderTypes = emptyList(),
|
||||
swapTxType = swapTxType,
|
||||
)
|
||||
|
||||
|
|
@ -42,12 +44,14 @@ class GetSwapSupportedPairsUseCase(
|
|||
filteringCurrency = { it.from },
|
||||
groupingCurrency = { it.to },
|
||||
cryptoCurrencyList = filteredOutInitial,
|
||||
allowedProviderTypes = filterProviderTypes,
|
||||
)
|
||||
val toGroup = pairs.groupPairs(
|
||||
initialCurrency = initialCurrency,
|
||||
filteringCurrency = { it.to },
|
||||
groupingCurrency = { it.from },
|
||||
cryptoCurrencyList = filteredOutInitial,
|
||||
allowedProviderTypes = filterProviderTypes,
|
||||
)
|
||||
|
||||
SwapCurrencies(
|
||||
|
|
@ -61,8 +65,9 @@ class GetSwapSupportedPairsUseCase(
|
|||
filteringCurrency: (SwapPairModel) -> CryptoCurrencyStatus,
|
||||
groupingCurrency: (SwapPairModel) -> CryptoCurrencyStatus,
|
||||
cryptoCurrencyList: List<CryptoCurrency>,
|
||||
allowedProviderTypes: List<ExpressProviderType>,
|
||||
): SwapCurrenciesGroup {
|
||||
val availableCryptoCurrencies = asSequence()
|
||||
val candidates = asSequence()
|
||||
.filter { pair ->
|
||||
filteringCurrency(pair).currency.id.rawCurrencyId == initialCurrency.id.rawCurrencyId
|
||||
}
|
||||
|
|
@ -75,18 +80,32 @@ class GetSwapSupportedPairsUseCase(
|
|||
.map { pair ->
|
||||
val toCurrency = groupingCurrency(pair)
|
||||
val isTxExtrasSupported = toCurrency.currency.network.transactionExtrasType.isTxExtrasSupported()
|
||||
val filteredProviders = if (isTxExtrasSupported) {
|
||||
pair.providers.filter { it.isExtraIdSupported }
|
||||
} else {
|
||||
pair.providers
|
||||
}
|
||||
SwapCryptoCurrency(toCurrency, filteredProviders)
|
||||
// Providers eligible for the current flow (e.g. Send with Swap): extra-id support + allowed type
|
||||
val eligibleProviders = pair.providers
|
||||
.filter { !isTxExtrasSupported || it.isExtraIdSupported }
|
||||
.filter { allowedProviderTypes.isEmpty() || it.type in allowedProviderTypes }
|
||||
// The pair has any provider => it can still be swapped in the regular Swap flow
|
||||
SwapCryptoCurrency(toCurrency, eligibleProviders) to pair.providers.isNotEmpty()
|
||||
}
|
||||
.filter { it.providers.isNotEmpty() }
|
||||
.toList()
|
||||
|
||||
val unavailableCryptoCurrencies =
|
||||
cryptoCurrencyList - availableCryptoCurrencies.map { it.currencyStatus.currency }.toSet()
|
||||
val availableCryptoCurrencies = candidates
|
||||
.filter { (currency, _) -> currency.providers.isNotEmpty() }
|
||||
.map { (currency, _) -> currency }
|
||||
val availableCurrencies = availableCryptoCurrencies.map { it.currencyStatus.currency }.toSet()
|
||||
|
||||
// Currencies with no providers for the current flow, but available in the regular Swap flow
|
||||
val availableForSwapCryptoCurrencies = candidates
|
||||
.filter { (currency, isAvailableForRegularSwap) ->
|
||||
currency.providers.isEmpty() && isAvailableForRegularSwap
|
||||
}
|
||||
.map { (currency, _) -> currency }
|
||||
.distinctBy { it.currencyStatus.currency }
|
||||
.filterNot { it.currencyStatus.currency in availableCurrencies }
|
||||
|
||||
val usedCurrencies = availableCurrencies +
|
||||
availableForSwapCryptoCurrencies.map { it.currencyStatus.currency }.toSet()
|
||||
val unavailableCryptoCurrencies = cryptoCurrencyList - usedCurrencies
|
||||
|
||||
return SwapCurrenciesGroup(
|
||||
available = availableCryptoCurrencies,
|
||||
|
|
@ -100,6 +119,7 @@ class GetSwapSupportedPairsUseCase(
|
|||
)
|
||||
},
|
||||
isAfterSearch = false,
|
||||
availableForSwap = availableForSwapCryptoCurrencies,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,190 @@
|
|||
package com.tangem.domain.swap.usecase
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.domain.express.models.ExpressProvider
|
||||
import com.tangem.domain.express.models.ExpressProviderType
|
||||
import com.tangem.domain.express.models.ExpressRateType
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.swap.SwapErrorResolver
|
||||
import com.tangem.domain.swap.SwapRepositoryV2
|
||||
import com.tangem.domain.swap.models.SwapPairModel
|
||||
import com.tangem.domain.swap.models.SwapTxType
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
internal class GetSwapSupportedPairsUseCaseTest {
|
||||
|
||||
private val swapRepositoryV2: SwapRepositoryV2 = mockk()
|
||||
private val swapErrorResolver: SwapErrorResolver = mockk()
|
||||
|
||||
private val useCase = GetSwapSupportedPairsUseCase(
|
||||
swapRepositoryV2 = swapRepositoryV2,
|
||||
swapErrorResolver = swapErrorResolver,
|
||||
)
|
||||
|
||||
private val initialCurrency = createCurrency("initial")
|
||||
private val cexCurrency = createCurrency("cex")
|
||||
private val dexOnlyCurrency = createCurrency("dex-only")
|
||||
private val unavailableCurrency = createCurrency("unavailable")
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
clearMocks(swapRepositoryV2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN cex, dex-only and missing currencies WHEN invoke THEN split into three buckets`() = runTest {
|
||||
// Arrange
|
||||
val pairs = listOf(
|
||||
createPair(from = initialCurrency, to = cexCurrency, providers = listOf(cexProvider)),
|
||||
createPair(from = initialCurrency, to = dexOnlyCurrency, providers = listOf(dexProvider)),
|
||||
// unavailableCurrency intentionally has no pair at all
|
||||
)
|
||||
coEvery {
|
||||
swapRepositoryV2.getSupportedPairs(any(), any(), any(), any(), any())
|
||||
} returns pairs
|
||||
|
||||
// Act
|
||||
val result = useCase.invoke(
|
||||
userWallet = userWallet,
|
||||
initialCurrency = initialCurrency,
|
||||
cryptoCurrencyList = listOf(initialCurrency, cexCurrency, dexOnlyCurrency, unavailableCurrency),
|
||||
filterProviderTypes = listOf(ExpressProviderType.CEX),
|
||||
swapTxType = SwapTxType.SendWithSwap,
|
||||
)
|
||||
|
||||
// Assert
|
||||
val fromGroup = result.getOrNull()!!.fromGroup
|
||||
assertThat(fromGroup.available.map { it.currencyStatus.currency }).containsExactly(cexCurrency)
|
||||
assertThat(fromGroup.availableForSwap.map { it.currencyStatus.currency }).containsExactly(dexOnlyCurrency)
|
||||
assertThat(fromGroup.unavailable.map { it.currencyStatus.currency }).containsExactly(unavailableCurrency)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN dex pair AND no type restriction WHEN invoke THEN currency is available not availableForSwap`() =
|
||||
runTest {
|
||||
// Arrange — allowedProviderTypes empty means any provider type is eligible for the current flow
|
||||
val pairs = listOf(
|
||||
createPair(from = initialCurrency, to = dexOnlyCurrency, providers = listOf(dexProvider)),
|
||||
)
|
||||
coEvery {
|
||||
swapRepositoryV2.getSupportedPairs(any(), any(), any(), any(), any())
|
||||
} returns pairs
|
||||
|
||||
// Act
|
||||
val result = useCase.invoke(
|
||||
userWallet = userWallet,
|
||||
initialCurrency = initialCurrency,
|
||||
cryptoCurrencyList = listOf(initialCurrency, dexOnlyCurrency),
|
||||
filterProviderTypes = emptyList(),
|
||||
swapTxType = SwapTxType.SendWithSwap,
|
||||
)
|
||||
|
||||
// Assert
|
||||
val fromGroup = result.getOrNull()!!.fromGroup
|
||||
assertThat(fromGroup.available.map { it.currencyStatus.currency }).containsExactly(dexOnlyCurrency)
|
||||
assertThat(fromGroup.availableForSwap).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN memo network with provider without extra-id WHEN invoke THEN currency is availableForSwap`() = runTest {
|
||||
// Arrange — to-network requires extra id, but the only CEX provider doesn't support it
|
||||
val memoCurrency = createCurrency(rawId = "memo", txExtras = Network.TransactionExtrasType.MEMO)
|
||||
val pairs = listOf(
|
||||
createPair(from = initialCurrency, to = memoCurrency, providers = listOf(cexProviderNoExtraId)),
|
||||
)
|
||||
coEvery {
|
||||
swapRepositoryV2.getSupportedPairs(any(), any(), any(), any(), any())
|
||||
} returns pairs
|
||||
|
||||
// Act
|
||||
val result = useCase.invoke(
|
||||
userWallet = userWallet,
|
||||
initialCurrency = initialCurrency,
|
||||
cryptoCurrencyList = listOf(initialCurrency, memoCurrency),
|
||||
filterProviderTypes = listOf(ExpressProviderType.CEX),
|
||||
swapTxType = SwapTxType.SendWithSwap,
|
||||
)
|
||||
|
||||
// Assert
|
||||
val fromGroup = result.getOrNull()!!.fromGroup
|
||||
assertThat(fromGroup.available).isEmpty()
|
||||
assertThat(fromGroup.availableForSwap.map { it.currencyStatus.currency }).containsExactly(memoCurrency)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN pair with both cex and dex providers WHEN invoke THEN currency is available not availableForSwap`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
val pairs = listOf(
|
||||
createPair(from = initialCurrency, to = cexCurrency, providers = listOf(cexProvider, dexProvider)),
|
||||
)
|
||||
coEvery {
|
||||
swapRepositoryV2.getSupportedPairs(any(), any(), any(), any(), any())
|
||||
} returns pairs
|
||||
|
||||
// Act
|
||||
val result = useCase.invoke(
|
||||
userWallet = userWallet,
|
||||
initialCurrency = initialCurrency,
|
||||
cryptoCurrencyList = listOf(initialCurrency, cexCurrency),
|
||||
filterProviderTypes = listOf(ExpressProviderType.CEX),
|
||||
swapTxType = SwapTxType.SendWithSwap,
|
||||
)
|
||||
|
||||
// Assert
|
||||
val fromGroup = result.getOrNull()!!.fromGroup
|
||||
assertThat(fromGroup.available.map { it.currencyStatus.currency }).containsExactly(cexCurrency)
|
||||
assertThat(fromGroup.availableForSwap).isEmpty()
|
||||
}
|
||||
|
||||
private fun createPair(from: CryptoCurrency, to: CryptoCurrency, providers: List<ExpressProvider>) = SwapPairModel(
|
||||
from = CryptoCurrencyStatus(currency = from, value = mockk(relaxed = true)),
|
||||
to = CryptoCurrencyStatus(currency = to, value = mockk(relaxed = true)),
|
||||
providers = providers,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
val userWallet: UserWallet = mockk(relaxed = true)
|
||||
|
||||
val cexProvider = createProvider("cex-1", ExpressProviderType.CEX, isExtraIdSupported = true)
|
||||
val cexProviderNoExtraId = createProvider("cex-2", ExpressProviderType.CEX, isExtraIdSupported = false)
|
||||
val dexProvider = createProvider("dex-1", ExpressProviderType.DEX, isExtraIdSupported = false)
|
||||
|
||||
fun createProvider(id: String, type: ExpressProviderType, isExtraIdSupported: Boolean) = ExpressProvider(
|
||||
providerId = id,
|
||||
rateTypes = listOf(ExpressRateType.Float),
|
||||
name = id,
|
||||
type = type,
|
||||
imageLarge = "",
|
||||
termsOfUse = null,
|
||||
privacyPolicy = null,
|
||||
slippage = null,
|
||||
isExtraIdSupported = isExtraIdSupported,
|
||||
)
|
||||
|
||||
fun createCurrency(
|
||||
rawId: String,
|
||||
txExtras: Network.TransactionExtrasType = Network.TransactionExtrasType.NONE,
|
||||
): CryptoCurrency {
|
||||
val id: CryptoCurrency.ID = mockk {
|
||||
every { rawCurrencyId } returns CryptoCurrency.RawID(rawId)
|
||||
every { rawNetworkId } returns rawId
|
||||
}
|
||||
return mockk<CryptoCurrency>(relaxed = true).also { currency ->
|
||||
every { currency.id } returns id
|
||||
every { currency.network.transactionExtrasType } returns txExtras
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -47,7 +47,7 @@ internal class SignUseCaseTest {
|
|||
val hash = byteArrayOf(1, 2, 3)
|
||||
val signature = byteArrayOf(9, 9)
|
||||
|
||||
every { cardSdkConfigRepository.getCommonSigner(any(), any()) } returns signer
|
||||
every { cardSdkConfigRepository.getCommonSigner(any(), any(), any()) } returns signer
|
||||
coEvery { walletManagersFacade.getOrCreateWalletManager(coldWallet.walletId, network) } returns walletManager
|
||||
coEvery { signer.sign(eq(hash), eq(walletManagerKey)) } returns CompletionResult.Success(signature)
|
||||
|
||||
|
|
@ -68,7 +68,7 @@ internal class SignUseCaseTest {
|
|||
val walletManager: WalletManager = mockk { every { wallet } returns mockk { every { publicKey } returns walletManagerKey } }
|
||||
val error: TangemError = mockk()
|
||||
|
||||
every { cardSdkConfigRepository.getCommonSigner(any(), any()) } returns signer
|
||||
every { cardSdkConfigRepository.getCommonSigner(any(), any(), any()) } returns signer
|
||||
coEvery { walletManagersFacade.getOrCreateWalletManager(coldWallet.walletId, network) } returns walletManager
|
||||
coEvery { signer.sign(any<ByteArray>(), any()) } returns CompletionResult.Failure(error)
|
||||
|
||||
|
|
@ -86,7 +86,7 @@ internal class SignUseCaseTest {
|
|||
val signer: TransactionSigner = mockk()
|
||||
val publicKeySlot = slot<Wallet.PublicKey>()
|
||||
|
||||
every { cardSdkConfigRepository.getCommonSigner(any(), any()) } returns signer
|
||||
every { cardSdkConfigRepository.getCommonSigner(any(), any(), any()) } returns signer
|
||||
coEvery { signer.sign(eq(hashes), capture(publicKeySlot)) } returns CompletionResult.Success(signatures)
|
||||
|
||||
// Act
|
||||
|
|
@ -98,7 +98,7 @@ internal class SignUseCaseTest {
|
|||
assertThat(publicKeySlot.captured.seedKey).isEqualTo(publicKey)
|
||||
assertThat(publicKeySlot.captured.derivationType).isNull()
|
||||
// Card is not backed up (backupStatus == null) and not a twin, so its id is passed to the signer
|
||||
verify(exactly = 1) { cardSdkConfigRepository.getCommonSigner(cardId = coldWallet.cardId, twinKey = null) }
|
||||
verify(exactly = 1) { cardSdkConfigRepository.getCommonSigner(cardId = coldWallet.cardId, twinKey = null, any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -127,7 +127,7 @@ internal class SignUseCaseTest {
|
|||
val signer: TransactionSigner = mockk()
|
||||
val error: TangemError = mockk { every { message } returns "Signing canceled" }
|
||||
|
||||
every { cardSdkConfigRepository.getCommonSigner(any(), any()) } returns signer
|
||||
every { cardSdkConfigRepository.getCommonSigner(any(), any(), any()) } returns signer
|
||||
coEvery { signer.sign(any<List<ByteArray>>(), any()) } returns CompletionResult.Failure(error)
|
||||
|
||||
// Act
|
||||
|
|
@ -147,7 +147,7 @@ internal class SignUseCaseTest {
|
|||
|
||||
// Assert
|
||||
assertThat(result.getOrNull()).isEmpty()
|
||||
verify(exactly = 0) { cardSdkConfigRepository.getCommonSigner(any(), any()) }
|
||||
verify(exactly = 0) { cardSdkConfigRepository.getCommonSigner(any(), any(), any()) }
|
||||
verify(exactly = 0) { getHotTransactionSigner(any()) }
|
||||
}
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ dependencies {
|
|||
/* Project - API */
|
||||
implementation(projects.features.manageTokens.api)
|
||||
implementation(projects.features.swapV2.api)
|
||||
implementation(projects.features.commonFeatures.api)
|
||||
|
||||
/* Project - Core */
|
||||
implementation(projects.core.decompose)
|
||||
|
|
@ -39,6 +40,7 @@ dependencies {
|
|||
implementation(projects.domain.wallets)
|
||||
implementation(projects.domain.wallets.models)
|
||||
implementation(projects.domain.swap.models)
|
||||
implementation(projects.domain.markets.models)
|
||||
implementation(projects.domain.notifications)
|
||||
implementation(projects.domain.dynamicAddresses)
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import com.tangem.core.decompose.context.AppComponentContext
|
|||
import com.tangem.core.decompose.context.childByContext
|
||||
import com.tangem.core.decompose.model.getOrCreateModel
|
||||
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
|
||||
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioComponent
|
||||
import com.tangem.features.managetokens.choosetoken.entity.ChooseManageTokensBottomSheetConfig
|
||||
import com.tangem.features.managetokens.choosetoken.model.ChooseManagedTokensModel
|
||||
import com.tangem.features.managetokens.choosetoken.ui.ChooseManagedTokenContent
|
||||
|
|
@ -28,6 +29,7 @@ internal class DefaultChooseManagedTokensComponent @AssistedInject constructor(
|
|||
@Assisted private val params: ChooseManagedTokensComponent.Params,
|
||||
private val swapChooseTokenNetworkFactory: SwapChooseTokenNetworkComponent.Factory,
|
||||
private val swapChooseTokenNetworkTrigger: SwapChooseTokenNetworkTrigger,
|
||||
private val addToPortfolioComponentFactory: AddToPortfolioComponent.Factory,
|
||||
) : ChooseManagedTokensComponent, AppComponentContext by context {
|
||||
|
||||
private val model: ChooseManagedTokensModel = getOrCreateModel(params)
|
||||
|
|
@ -76,8 +78,15 @@ internal class DefaultChooseManagedTokensComponent @AssistedInject constructor(
|
|||
params.callback?.onResult() ?: router.pop()
|
||||
}
|
||||
},
|
||||
onSwapClick = { toCryptoCurrency ->
|
||||
model.onSwapTokenClick(token = config.token, targetCurrency = toCryptoCurrency)
|
||||
},
|
||||
),
|
||||
)
|
||||
ChooseManageTokensBottomSheetConfig.AddToPortfolioBottomSheetConfig -> addToPortfolioComponentFactory.create(
|
||||
context = childByContext(componentContext),
|
||||
params = AddToPortfolioComponent.Params(addToPortfolioManager = model.addToPortfolioManager),
|
||||
)
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
|
|
|
|||
|
|
@ -16,4 +16,8 @@ internal sealed class ChooseManageTokensBottomSheetConfig {
|
|||
val token: ManagedCryptoCurrency.Token,
|
||||
val isSearchedToken: Boolean,
|
||||
) : ChooseManageTokensBottomSheetConfig()
|
||||
|
||||
/** Add the swap target token to the portfolio before opening the regular Swap screen. */
|
||||
@Serializable
|
||||
data object AddToPortfolioBottomSheetConfig : ChooseManageTokensBottomSheetConfig()
|
||||
}
|
||||
|
|
@ -3,6 +3,8 @@ package com.tangem.features.managetokens.choosetoken.model
|
|||
import androidx.annotation.StringRes
|
||||
import com.arkivanov.decompose.router.slot.SlotNavigation
|
||||
import com.arkivanov.decompose.router.slot.activate
|
||||
import com.arkivanov.decompose.router.slot.dismiss
|
||||
import com.tangem.common.routing.AppRoute
|
||||
import com.tangem.common.ui.notifications.NotificationId
|
||||
import com.tangem.common.ui.notifications.NotificationUM
|
||||
import com.tangem.core.analytics.api.AnalyticsEventHandler
|
||||
|
|
@ -11,7 +13,13 @@ import com.tangem.core.decompose.model.Model
|
|||
import com.tangem.core.decompose.model.ParamsContainer
|
||||
import com.tangem.core.decompose.navigation.Router
|
||||
import com.tangem.core.decompose.ui.UiMessageSender
|
||||
import com.tangem.core.analytics.models.AnalyticsParam
|
||||
import com.tangem.core.ui.components.fields.entity.SearchBarUM
|
||||
import com.tangem.domain.managetokens.model.ManagedCryptoCurrency
|
||||
import com.tangem.domain.markets.RawMarketToken
|
||||
import com.tangem.domain.markets.TokenMarketInfo
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager
|
||||
import com.tangem.core.ui.event.consumedEvent
|
||||
import com.tangem.core.ui.event.triggeredEvent
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
|
|
@ -55,10 +63,19 @@ internal class ChooseManagedTokensModel @Inject constructor(
|
|||
paramsContainer: ParamsContainer,
|
||||
manageTokensUseCasesFacadeFactory: ManageTokensUseCasesFacade.Factory,
|
||||
manageTokensListManagerFactory: ManageTokensListManager.Factory,
|
||||
addToPortfolioManagerFactory: AddToPortfolioManager.Factory,
|
||||
) : Model() {
|
||||
|
||||
private val params: ChooseManagedTokensComponent.Params = paramsContainer.require()
|
||||
|
||||
val addToPortfolioManager: AddToPortfolioManager = addToPortfolioManagerFactory.create(
|
||||
scope = modelScope,
|
||||
settings = AddToPortfolioManager.Settings.ChooseToken,
|
||||
analyticsParams = AddToPortfolioManager.AnalyticsParams(
|
||||
source = AnalyticsParam.ScreensSources.Send.value,
|
||||
),
|
||||
)
|
||||
|
||||
private val manageTokensMode = ManageTokensMode.Account(params.userWalletId)
|
||||
|
||||
private val useCasesFacade: ManageTokensUseCasesFacade = manageTokensUseCasesFacadeFactory
|
||||
|
|
@ -97,11 +114,63 @@ internal class ChooseManagedTokensModel @Inject constructor(
|
|||
|
||||
observeSearchQueryChanges()
|
||||
|
||||
addToPortfolioManager.onSuccessAdded.receiveAsFlow()
|
||||
.onEach { result -> navigateToSwap(toCryptoCurrency = result.addedCurrency.currency) }
|
||||
.launchIn(modelScope)
|
||||
addToPortfolioManager.onDismiss.receiveAsFlow()
|
||||
.onEach { bottomSheetNavigation.dismiss() }
|
||||
.launchIn(modelScope)
|
||||
|
||||
modelScope.launch {
|
||||
manageTokensListManager.launchPagination(isCollapsed = false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoked from the Send-with-Swap "Swap token" notice when the token is only swappable in the regular
|
||||
* Swap flow. If the token isn't added to the wallet yet, shows the add-to-portfolio bottom sheet first;
|
||||
* otherwise opens the Swap screen directly with [targetCurrency] pre-selected as TO.
|
||||
*/
|
||||
fun onSwapTokenClick(token: ManagedCryptoCurrency.Token, targetCurrency: CryptoCurrency) {
|
||||
if (token.isAdded) {
|
||||
navigateToSwap(toCryptoCurrency = targetCurrency)
|
||||
} else {
|
||||
addToPortfolioManager.setTokenNetworks(token.toMarketNetworks())
|
||||
addToPortfolioManager.setTokenParams(token.toRawMarketToken())
|
||||
bottomSheetNavigation.activate(ChooseManageTokensBottomSheetConfig.AddToPortfolioBottomSheetConfig)
|
||||
}
|
||||
}
|
||||
|
||||
private fun navigateToSwap(toCryptoCurrency: CryptoCurrency) {
|
||||
bottomSheetNavigation.dismiss()
|
||||
router.push(
|
||||
AppRoute.Swap(
|
||||
userWalletId = params.userWalletId,
|
||||
fromCryptoCurrency = params.initialCurrency,
|
||||
toCryptoCurrency = toCryptoCurrency,
|
||||
screenSource = AnalyticsParam.ScreensSources.Send.value,
|
||||
fromCurrencyPosition = AppRoute.Swap.CurrencyPosition.FROM,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun ManagedCryptoCurrency.Token.toRawMarketToken(): RawMarketToken = RawMarketToken(
|
||||
id = CryptoCurrency.RawID(id.value),
|
||||
name = name,
|
||||
symbol = symbol,
|
||||
)
|
||||
|
||||
private fun ManagedCryptoCurrency.Token.toMarketNetworks(): List<TokenMarketInfo.Network> {
|
||||
return availableNetworks.map { sourceNetwork ->
|
||||
TokenMarketInfo.Network(
|
||||
networkId = sourceNetwork.network.rawId,
|
||||
isExchangeable = false,
|
||||
contractAddress = (sourceNetwork as? ManagedCryptoCurrency.SourceNetwork.Default)?.contractAddress,
|
||||
decimalCount = sourceNetwork.decimals,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createReadContentModel(): ChooseManagedTokenUM {
|
||||
return ChooseManagedTokenUM(
|
||||
notificationUM = getNotification(),
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@ internal class SwapSelectTokensModel @Inject constructor(
|
|||
|
||||
router.push(
|
||||
route = AppRoute.Swap(
|
||||
cryptoCurrency = requireNotNull(fromCurrencyStatus.value).currency,
|
||||
fromCryptoCurrency = requireNotNull(fromCurrencyStatus.value).currency,
|
||||
userWalletId = params.userWalletId,
|
||||
screenSource = AnalyticsParam.ScreensSources.Main.value,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ interface SwapChooseTokenNetworkComponent : ComposableBottomSheetComponent {
|
|||
val isSearchedToken: Boolean,
|
||||
val onDismiss: () -> Unit,
|
||||
val onResult: (SwapCurrencies, CryptoCurrency) -> Unit,
|
||||
val onSwapClick: (CryptoCurrency) -> Unit,
|
||||
)
|
||||
|
||||
interface Factory : ComponentFactory<Params, SwapChooseTokenNetworkComponent>
|
||||
|
|
|
|||
|
|
@ -110,7 +110,7 @@ data class PriceImpact(
|
|||
}
|
||||
|
||||
fun shouldShowWarning(): Boolean {
|
||||
return type.ordinal > Type.LOW.ordinal || amountSignificance.ordinal > AmountSignificance.LOW.ordinal
|
||||
return type.ordinal > Type.LOW.ordinal && amountSignificance.ordinal > AmountSignificance.LOW.ordinal
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
|
|
|||
|
|
@ -25,6 +25,14 @@ internal sealed class SwapChooseTokenNetworkContentUM : TangemBottomSheetConfigC
|
|||
override val messageContent: MessageBottomSheetUM,
|
||||
) : SwapChooseTokenNetworkContentUM()
|
||||
|
||||
/**
|
||||
* Token has no networks available for Send with Swap, but the pair is available in the regular Swap flow.
|
||||
* Informs the user (rendered like [Error], with a different message).
|
||||
*/
|
||||
data class SwapAvailable(
|
||||
override val messageContent: MessageBottomSheetUM,
|
||||
) : SwapChooseTokenNetworkContentUM()
|
||||
|
||||
data class Content(
|
||||
override val messageContent: MessageBottomSheetUM,
|
||||
val swapNetworks: ImmutableList<SwapChooseNetworkUM>,
|
||||
|
|
|
|||
|
|
@ -23,4 +23,24 @@ internal object SwapChooseTokenFactory {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getSwapAvailableMessage(tokenName: String, onSwapClick: () -> Unit): MessageBottomSheetUM {
|
||||
return messageBottomSheetUM {
|
||||
infoBlock {
|
||||
icon(R.drawable.ic_alert_triangle_20) {
|
||||
type = MessageBottomSheetUM.Icon.Type.Attention
|
||||
backgroundType = MessageBottomSheetUM.Icon.BackgroundType.SameAsTint
|
||||
}
|
||||
title = resourceReference(
|
||||
R.string.express_send_with_swap_not_supported_title,
|
||||
wrappedList(tokenName),
|
||||
)
|
||||
body = resourceReference(R.string.express_send_with_swap_not_supported_text)
|
||||
}
|
||||
primaryButton {
|
||||
text = resourceReference(R.string.express_send_with_swap_not_supported_button)
|
||||
onClick { onSwapClick() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -99,12 +99,18 @@ internal class SwapChooseTokenNetworkModel @Inject constructor(
|
|||
}
|
||||
delay(MINIMUM_LOADING_TIME)
|
||||
if (pairs.fromGroup.available.isEmpty()) {
|
||||
analyticsEventHandler.send(
|
||||
val analyticsEvent = if (pairs.fromGroup.availableForSwap.isNotEmpty()) {
|
||||
SendWithSwapAnalyticEvents.NoticeSwapAvailable(
|
||||
fromToken = params.initialCurrency,
|
||||
toTokenSymbol = params.token.symbol,
|
||||
)
|
||||
} else {
|
||||
SendWithSwapAnalyticEvents.NoticeCanNotSwapToken(
|
||||
fromToken = params.initialCurrency,
|
||||
toTokenSymbol = params.token.symbol,
|
||||
),
|
||||
)
|
||||
)
|
||||
}
|
||||
analyticsEventHandler.send(analyticsEvent)
|
||||
}
|
||||
uiState.update(
|
||||
SwapChooseContentStateTransformer(
|
||||
|
|
@ -112,6 +118,7 @@ internal class SwapChooseTokenNetworkModel @Inject constructor(
|
|||
onNetworkClick = ::onSwapTokenClick,
|
||||
tokenName = params.token.name,
|
||||
onDismiss = params.onDismiss,
|
||||
onSwapClick = params.onSwapClick,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import com.tangem.features.swap.v2.impl.choosetoken.fromSupported.entity.SwapCho
|
|||
import com.tangem.features.swap.v2.impl.choosetoken.fromSupported.entity.SwapChooseTokenNetworkContentUM
|
||||
import com.tangem.features.swap.v2.impl.choosetoken.fromSupported.entity.SwapChooseTokenNetworkUM
|
||||
import com.tangem.features.swap.v2.impl.choosetoken.fromSupported.model.SwapChooseTokenFactory.getErrorMessage
|
||||
import com.tangem.features.swap.v2.impl.choosetoken.fromSupported.model.SwapChooseTokenFactory.getSwapAvailableMessage
|
||||
import com.tangem.lib.crypto.BlockchainUtils
|
||||
import com.tangem.utils.transformer.Transformer
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
|
|
@ -21,6 +22,7 @@ internal class SwapChooseContentStateTransformer(
|
|||
private val tokenName: String,
|
||||
private val onNetworkClick: (SwapCurrencies, CryptoCurrency) -> Unit,
|
||||
private val onDismiss: () -> Unit,
|
||||
private val onSwapClick: (CryptoCurrency) -> Unit,
|
||||
) : Transformer<SwapChooseTokenNetworkUM> {
|
||||
override fun transform(prevState: SwapChooseTokenNetworkUM): SwapChooseTokenNetworkUM {
|
||||
val swapNetworks = pairs.fromGroup.available.map { availableCurrency ->
|
||||
|
|
@ -51,16 +53,26 @@ internal class SwapChooseContentStateTransformer(
|
|||
|
||||
return prevState.copy(
|
||||
bottomSheetConfig = prevState.bottomSheetConfig.copy(
|
||||
content = if (swapNetworks.isNotEmpty()) {
|
||||
SwapChooseTokenNetworkContentUM.Content(
|
||||
content = when {
|
||||
swapNetworks.isNotEmpty() -> SwapChooseTokenNetworkContentUM.Content(
|
||||
swapNetworks = swapNetworks,
|
||||
messageContent = getErrorMessage(
|
||||
tokenName = tokenName,
|
||||
onDismiss = onDismiss,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
SwapChooseTokenNetworkContentUM.Error(
|
||||
// No networks for Send with Swap, but the pair is available in the regular Swap flow
|
||||
pairs.fromGroup.availableForSwap.isNotEmpty() -> {
|
||||
val firstAvailableForSwap = pairs.fromGroup.availableForSwap.first()
|
||||
val swapTargetCurrency = firstAvailableForSwap.currencyStatus.currency
|
||||
SwapChooseTokenNetworkContentUM.SwapAvailable(
|
||||
messageContent = getSwapAvailableMessage(
|
||||
tokenName = tokenName,
|
||||
onSwapClick = { onSwapClick(swapTargetCurrency) },
|
||||
),
|
||||
)
|
||||
}
|
||||
else -> SwapChooseTokenNetworkContentUM.Error(
|
||||
messageContent = getErrorMessage(
|
||||
tokenName = tokenName,
|
||||
onDismiss = onDismiss,
|
||||
|
|
|
|||
|
|
@ -112,6 +112,19 @@ internal sealed class SendWithSwapAnalyticEvents(
|
|||
),
|
||||
)
|
||||
|
||||
/** Token can't be used in Send with Swap, but is available in the regular Swap flow */
|
||||
data class NoticeSwapAvailable(
|
||||
val fromToken: CryptoCurrency,
|
||||
val toTokenSymbol: String,
|
||||
) : SendWithSwapAnalyticEvents(
|
||||
event = "Notice - Swap Available",
|
||||
params = mapOf(
|
||||
SEND_TOKEN to fromToken.symbol,
|
||||
RECEIVE_TOKEN to toTokenSymbol,
|
||||
SEND_BLOCKCHAIN to fromToken.network.name,
|
||||
),
|
||||
)
|
||||
|
||||
data object NoticeFixedRate : SendWithSwapAnalyticEvents(
|
||||
event = "Notice - Fixed Rate",
|
||||
params = emptyMap(),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,80 @@
|
|||
package com.tangem.features.swap.v2.impl.choosetoken.fromSupported.model.transformers
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.swap.models.SwapCryptoCurrency
|
||||
import com.tangem.domain.swap.models.SwapCurrencies
|
||||
import com.tangem.domain.swap.models.SwapCurrenciesGroup
|
||||
import com.tangem.features.swap.v2.impl.choosetoken.fromSupported.entity.SwapChooseTokenNetworkContentUM
|
||||
import com.tangem.features.swap.v2.impl.choosetoken.fromSupported.entity.SwapChooseTokenNetworkUM
|
||||
import com.tangem.features.swap.v2.impl.choosetoken.fromSupported.model.SwapChooseTokenFactory
|
||||
import io.mockk.mockk
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
internal class SwapChooseContentStateTransformerTest {
|
||||
|
||||
private val tokenName = "Shiba Inu"
|
||||
|
||||
@Test
|
||||
fun `GIVEN no available but availableForSwap present WHEN transform THEN content is SwapAvailable`() {
|
||||
// Arrange
|
||||
val pairs = swapCurrencies(available = emptyList(), availableForSwap = listOf(swapCryptoCurrency()))
|
||||
|
||||
// Act
|
||||
val result = transformer(pairs).transform(prevState())
|
||||
|
||||
// Assert
|
||||
assertThat(result.bottomSheetConfig.content)
|
||||
.isInstanceOf(SwapChooseTokenNetworkContentUM.SwapAvailable::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN no available and no availableForSwap WHEN transform THEN content is Error`() {
|
||||
// Arrange
|
||||
val pairs = swapCurrencies(available = emptyList(), availableForSwap = emptyList())
|
||||
|
||||
// Act
|
||||
val result = transformer(pairs).transform(prevState())
|
||||
|
||||
// Assert
|
||||
assertThat(result.bottomSheetConfig.content)
|
||||
.isInstanceOf(SwapChooseTokenNetworkContentUM.Error::class.java)
|
||||
}
|
||||
|
||||
private fun transformer(pairs: SwapCurrencies) = SwapChooseContentStateTransformer(
|
||||
pairs = pairs,
|
||||
tokenName = tokenName,
|
||||
onNetworkClick = { _, _ -> },
|
||||
onDismiss = {},
|
||||
onSwapClick = {},
|
||||
)
|
||||
|
||||
private fun swapCurrencies(
|
||||
available: List<SwapCryptoCurrency>,
|
||||
availableForSwap: List<SwapCryptoCurrency>,
|
||||
): SwapCurrencies = SwapCurrencies.EMPTY.copy(
|
||||
fromGroup = SwapCurrenciesGroup(
|
||||
available = available,
|
||||
unavailable = emptyList(),
|
||||
isAfterSearch = false,
|
||||
availableForSwap = availableForSwap,
|
||||
),
|
||||
)
|
||||
|
||||
private fun swapCryptoCurrency(): SwapCryptoCurrency = SwapCryptoCurrency(
|
||||
currencyStatus = CryptoCurrencyStatus(currency = mockk<CryptoCurrency>(relaxed = true), value = mockk(relaxed = true)),
|
||||
providers = emptyList(),
|
||||
)
|
||||
|
||||
private fun prevState(): SwapChooseTokenNetworkUM = SwapChooseTokenNetworkUM(
|
||||
bottomSheetConfig = TangemBottomSheetConfig(
|
||||
isShown = true,
|
||||
onDismissRequest = {},
|
||||
content = SwapChooseTokenNetworkContentUM.Loading(
|
||||
messageContent = SwapChooseTokenFactory.getErrorMessage(tokenName = tokenName, onDismiss = {}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -30,7 +30,7 @@ features/swap/
|
|||
|
||||
| Symbol | Role | Path |
|
||||
|---|---|---|
|
||||
| `SwapComponent` | API entry point; `Params(userWalletId, cryptoCurrency?, screenSource, currencyPosition, tangemPayInput)` | `api/.../features/swap/SwapComponent.kt` |
|
||||
| `SwapComponent` | API entry point; `Params(userWalletId, cryptoCurrency?, screenSource, currencyPosition, tangemPayInput, toCryptoCurrency?)` | `api/.../features/swap/SwapComponent.kt` |
|
||||
| `DefaultSwapComponent` | Decompose component; creates `SwapModel`, owns the child stack + slots | `impl/.../feature/swap/DefaultSwapComponent.kt` |
|
||||
| `SwapModel` | Central coordinator (~2100 lines). State holder + fee-selector bridge | `impl/.../feature/swap/model/SwapModel.kt` |
|
||||
| `SwapProcessDataState` | Live domain state for the session (tokens, pairs, providers, `swapDataModel`, amount) | `impl/.../feature/swap/model/SwapProcessDataState.kt` |
|
||||
|
|
@ -39,6 +39,11 @@ features/swap/
|
|||
| `SwapInteractor` | Domain API; `loadSwapFee` / `applySwapFee` are the unified fee entry points | `domain/.../feature/swap/domain/SwapInteractor.kt` |
|
||||
| `SwapInteractorImpl` | ~28 deps; `findBestQuote` dispatches per-provider via `supervisorScope + async` | `domain/.../feature/swap/domain/SwapInteractorImpl.kt` |
|
||||
|
||||
`toCryptoCurrency` pre-selects the **TO** (receive) token, but only if it is already present in the user's
|
||||
crypto portfolio — resolved by `InitialCurrenciesResolver` (matched by token identity / `isSameTokenAs`,
|
||||
preferring the FROM account's instance). If the token isn't in the wallet, the TO slot stays empty. Used by
|
||||
Send-with-Swap's "Swap token" notice when a pair is available only in the regular Swap flow.
|
||||
|
||||
`SwapModel` state worth knowing: `dataStateStateFlow` (reactive domain data) and
|
||||
`uiState: SwapStateHolder` (Compose state); the inner `FeeSelectorRepository` wires the
|
||||
send-v2 fee selector to `SwapInteractor.loadSwapFee`/`applySwapFee`.
|
||||
|
|
|
|||
|
|
@ -10,10 +10,11 @@ interface SwapComponent : ComposableContentComponent {
|
|||
|
||||
data class Params(
|
||||
val userWalletId: UserWalletId,
|
||||
val cryptoCurrency: CryptoCurrency? = null,
|
||||
val fromCryptoCurrency: CryptoCurrency? = null,
|
||||
val screenSource: String,
|
||||
val currencyPosition: CurrencyPosition = CurrencyPosition.ANY,
|
||||
val fromCurrencyPosition: CurrencyPosition = CurrencyPosition.ANY,
|
||||
val tangemPayInput: TangemPayInput? = null,
|
||||
val toCryptoCurrency: CryptoCurrency? = null,
|
||||
) {
|
||||
data class TangemPayInput(
|
||||
val cryptoAmount: BigDecimal,
|
||||
|
|
|
|||
|
|
@ -9,4 +9,5 @@ interface SwapFeatureToggles {
|
|||
val isSwapRateExperienceEnabled: Boolean
|
||||
val isSwapPredefinedButtonsEnabled: Boolean
|
||||
val isExpressShareButtonEnabled: Boolean
|
||||
val isSwapBestDexRateEnabled: Boolean
|
||||
}
|
||||
|
|
@ -63,6 +63,9 @@ enum class ExchangeProviderType(val providerName: String) {
|
|||
DEX_BRIDGE("DEX/Bridge"),
|
||||
;
|
||||
|
||||
/** Returns true for DEX-based providers ([DEX] and [DEX_BRIDGE]). */
|
||||
fun isDex(): Boolean = this == DEX || this == DEX_BRIDGE
|
||||
|
||||
companion object {
|
||||
fun getSwapProviderTypes(): List<ExchangeProviderType> {
|
||||
return listOf(CEX, DEX, DEX_BRIDGE)
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ data class PriceImpact(
|
|||
}
|
||||
|
||||
fun shouldShowWarning(): Boolean {
|
||||
return type.ordinal > Type.LOW.ordinal || amountSignificance.ordinal > AmountSignificance.LOW.ordinal
|
||||
return type.ordinal > Type.LOW.ordinal && amountSignificance.ordinal > AmountSignificance.LOW.ordinal
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
|
|
|||
|
|
@ -47,4 +47,9 @@ internal class DefaultSwapFeatureToggles @Inject constructor(
|
|||
get() = featureTogglesManager.isFeatureEnabled(
|
||||
toggle = FeatureToggles.AND_15489_EXPRESS_SHARE_BUTTON_ENABLED,
|
||||
)
|
||||
|
||||
override val isSwapBestDexRateEnabled: Boolean
|
||||
get() = featureTogglesManager.isFeatureEnabled(
|
||||
toggle = FeatureToggles.AND_15715_SWAP_BEST_DEX_RATE_ENABLED,
|
||||
) && isSwapIntegratedApproveEnabled
|
||||
}
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
package com.tangem.feature.swap.converters
|
||||
|
||||
import com.tangem.feature.swap.domain.models.domain.SwapProvider
|
||||
import com.tangem.feature.swap.domain.models.ui.PermissionDataState
|
||||
import com.tangem.feature.swap.domain.models.ui.SwapState
|
||||
import com.tangem.feature.swap.model.consideredProvidersStates
|
||||
import com.tangem.feature.swap.models.states.ProviderState
|
||||
import com.tangem.feature.swap.models.states.ProviderState.AdditionalBadge
|
||||
import com.tangem.utils.isNullOrZero
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
|
||||
/**
|
||||
* Pure provider-level decisions for the swap UI:
|
||||
* - [findBest] — which provider is the "best" among the loaded quotes, and
|
||||
* - [resolveBadge] — which [ProviderState.AdditionalBadge] a provider row should show.
|
||||
*/
|
||||
internal object SwapProviderResolver {
|
||||
|
||||
private val FCA_RESTRICTED_PROVIDER_IDS = setOf(
|
||||
"changelly",
|
||||
"changenow",
|
||||
"okx-cross-chain",
|
||||
"okx-on-chain",
|
||||
"simpleswap",
|
||||
)
|
||||
|
||||
/**
|
||||
* Picks the best provider among [states].
|
||||
*
|
||||
* When [isSwapBestDexRateEnabled] is on and at least one DEX/DEX_BRIDGE provider is present, the
|
||||
* best-rated DEX provider wins; otherwise the overall best-rated provider is returned (the best
|
||||
* CEX when no DEX is available). "Best rated" = lowest from/to fiat ratio (most output per unit
|
||||
* of input). Returns null when [states] is empty.
|
||||
*
|
||||
* @param isSwapBestDexRateEnabled whether the Best DEX Rate feature toggle is on.
|
||||
*/
|
||||
fun findBest(
|
||||
states: Map<SwapProvider, SwapState.QuotesLoadedState>,
|
||||
isSwapBestDexRateEnabled: Boolean,
|
||||
): SwapProvider? {
|
||||
if (!isSwapBestDexRateEnabled) return findBestRated(states)
|
||||
val dexStates = states.filterKeys { it.type.isDex() }
|
||||
return if (dexStates.isNotEmpty()) {
|
||||
findBestRated(dexStates)
|
||||
} else {
|
||||
findBestRated(states)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the badge for a single provider row.
|
||||
*
|
||||
* Priority: FCA restriction → permission required → recommended → best rate → none. A best-rate
|
||||
* badge is shown only when more than one provider is considered, FCA restrictions are not applied,
|
||||
* and this row's quote carries no price-impact warning. Which best-rate badge it is depends on the
|
||||
* provider mix (only relevant when [isSwapBestDexRateEnabled] is on):
|
||||
* - [AdditionalBadge.BestTrade] ("Best rate") — always on the overall best-rated provider,
|
||||
* regardless of its type.
|
||||
* - [AdditionalBadge.BestDexRate] ("Best DEX rate") — only when both CEX and DEX providers are
|
||||
* present and a CEX is the overall best (so the best DEX is not the overall best); it is then
|
||||
* shown on the best-rated DEX. When a DEX already is the overall best, or the set is CEX-only /
|
||||
* DEX-only, no separate "Best DEX rate" badge is shown.
|
||||
*
|
||||
* When [isSwapBestDexRateEnabled] is off, only the overall best provider gets [AdditionalBadge.BestTrade]
|
||||
* (legacy behaviour) and [AdditionalBadge.BestDexRate] is never produced.
|
||||
*
|
||||
* @param states all loaded quotes — used to find the best providers and to count considered providers.
|
||||
* @param provider the provider this row represents.
|
||||
* @param needApplyFCARestrictions whether FCA restrictions apply to the current user.
|
||||
* @param state this provider's [SwapState]; price-impact and permission are read from it when it
|
||||
* is a [SwapState.QuotesLoadedState]. Null for error rows (which only resolve to FCA / recommended / none).
|
||||
* @param isSwapBestDexRateEnabled whether the Best DEX Rate feature toggle is on.
|
||||
*/
|
||||
fun resolveBadge(
|
||||
states: Map<SwapProvider, SwapState.QuotesLoadedState>,
|
||||
provider: SwapProvider,
|
||||
needApplyFCARestrictions: Boolean,
|
||||
state: SwapState? = null,
|
||||
isSwapBestDexRateEnabled: Boolean,
|
||||
): AdditionalBadge {
|
||||
val priceImpact = (state as? SwapState.QuotesLoadedState)?.priceImpact
|
||||
val permissionState = (state as? SwapState.QuotesLoadedState)?.permissionState
|
||||
|
||||
val isNeedBestRateBadge = states.consideredProvidersStates().size > 1
|
||||
val isBestRateBadgeAllowed = !needApplyFCARestrictions && isNeedBestRateBadge &&
|
||||
priceImpact != null && !priceImpact.shouldShowWarning()
|
||||
|
||||
return when {
|
||||
needApplyFCARestrictions && provider.isFCARestricted() -> AdditionalBadge.FCAWarningList
|
||||
permissionState is PermissionDataState.PermissionRequired -> AdditionalBadge.PermissionRequired
|
||||
provider.isRecommended -> AdditionalBadge.Recommended
|
||||
isBestRateBadgeAllowed -> resolveBestRateBadge(states, provider, isSwapBestDexRateEnabled)
|
||||
else -> AdditionalBadge.Empty
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks the best-rate badge for [provider] once it has passed the eligibility gate in [resolveBadge].
|
||||
* Returns [AdditionalBadge.Empty] when this row is neither the overall best nor the eligible best DEX.
|
||||
*/
|
||||
private fun resolveBestRateBadge(
|
||||
states: Map<SwapProvider, SwapState.QuotesLoadedState>,
|
||||
provider: SwapProvider,
|
||||
isSwapBestDexRateEnabled: Boolean,
|
||||
): AdditionalBadge {
|
||||
val overallBest = findBestRated(states)
|
||||
val isOverallBest = provider.providerId == overallBest?.providerId
|
||||
|
||||
// Toggle off → legacy behaviour: only the overall best provider gets the "Best rate" badge.
|
||||
if (!isSwapBestDexRateEnabled) {
|
||||
return if (isOverallBest) AdditionalBadge.BestTrade else AdditionalBadge.Empty
|
||||
}
|
||||
|
||||
val dexStates = states.filterKeys { it.type.isDex() }
|
||||
val hasDex = dexStates.isNotEmpty()
|
||||
val hasCex = states.keys.any { !it.type.isDex() }
|
||||
val bestDex = findBestRated(dexStates)
|
||||
val isBestDex = bestDex != null && provider.providerId == bestDex.providerId
|
||||
// Both types present and a CEX is the overall best (i.e. overall best != best DEX).
|
||||
val isCexBeatsDex = hasDex && hasCex && overallBest?.providerId != bestDex?.providerId
|
||||
|
||||
return when {
|
||||
isOverallBest -> AdditionalBadge.BestTrade
|
||||
isCexBeatsDex && isBestDex -> AdditionalBadge.BestDexRate
|
||||
else -> AdditionalBadge.Empty
|
||||
}
|
||||
}
|
||||
|
||||
/** Best provider following the default best-rate behaviour over all providers. */
|
||||
private fun findBestRated(states: Map<SwapProvider, SwapState.QuotesLoadedState>): SwapProvider? {
|
||||
return states.minByOrNull { entry -> entry.value.rateRatio() }?.key
|
||||
}
|
||||
|
||||
private fun SwapProvider.isFCARestricted(): Boolean = providerId in FCA_RESTRICTED_PROVIDER_IDS
|
||||
|
||||
private fun SwapState.QuotesLoadedState.rateRatio(): BigDecimal {
|
||||
val fromAmountFiat = fromTokenInfo.amountFiat
|
||||
val toAmountFiat = toTokenInfo.amountFiat
|
||||
return if (!fromAmountFiat.isNullOrZero() && !toAmountFiat.isNullOrZero()) {
|
||||
fromAmountFiat.divide(
|
||||
toAmountFiat,
|
||||
toTokenInfo.swapCurrencyStatus.currency.decimals,
|
||||
RoundingMode.HALF_UP,
|
||||
)
|
||||
} else {
|
||||
BigDecimal.ZERO
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -7,56 +7,36 @@ import com.tangem.core.ui.format.bigdecimal.crypto
|
|||
import com.tangem.core.ui.format.bigdecimal.format
|
||||
import com.tangem.feature.swap.domain.models.domain.SwapProvider
|
||||
import com.tangem.feature.swap.domain.models.ui.PermissionDataState
|
||||
import com.tangem.feature.swap.domain.models.ui.SwapState
|
||||
import com.tangem.feature.swap.domain.models.ui.TokenSwapInfo
|
||||
import com.tangem.feature.swap.models.states.PercentDifference
|
||||
import com.tangem.feature.swap.models.states.ProviderState
|
||||
|
||||
/**
|
||||
* Builds [ProviderState.Content] for the swap provider list / row.
|
||||
*
|
||||
* Pure: takes everything it needs as parameters. Designed to be unit-tested in isolation.
|
||||
*/
|
||||
internal object SwapProviderStateBuilder {
|
||||
|
||||
private val FCA_RESTRICTED_PROVIDER_IDS = setOf(
|
||||
"changelly",
|
||||
"changenow",
|
||||
"okx-cross-chain",
|
||||
"okx-on-chain",
|
||||
"simpleswap",
|
||||
)
|
||||
|
||||
/**
|
||||
* Provider row on the main swap screen — shows the exchange rate `1 base ≈ rate quote`
|
||||
* (see [SwapRateFormatter]) and allows the user to open the provider picker.
|
||||
*/
|
||||
@Suppress("LongParameterList")
|
||||
fun buildContentClickable(
|
||||
provider: SwapProvider,
|
||||
fromTokenInfo: TokenSwapInfo,
|
||||
toTokenInfo: TokenSwapInfo,
|
||||
permissionState: PermissionDataState,
|
||||
state: SwapState.QuotesLoadedState,
|
||||
selectionType: ProviderState.SelectionType,
|
||||
isBestRate: Boolean,
|
||||
isNeedBestRateBadge: Boolean,
|
||||
needApplyFCARestrictions: Boolean,
|
||||
additionalBadge: ProviderState.AdditionalBadge,
|
||||
onProviderClick: (String) -> Unit,
|
||||
): ProviderState.Content {
|
||||
val rateString = SwapRateFormatter.formatRate(
|
||||
from = fromTokenInfo.swapCurrencyStatus.currency,
|
||||
to = toTokenInfo.swapCurrencyStatus.currency,
|
||||
fromAmount = fromTokenInfo.tokenAmount.value,
|
||||
toAmount = toTokenInfo.tokenAmount.value,
|
||||
from = state.fromTokenInfo.swapCurrencyStatus.currency,
|
||||
to = state.toTokenInfo.swapCurrencyStatus.currency,
|
||||
fromAmount = state.fromTokenInfo.tokenAmount.value,
|
||||
toAmount = state.toTokenInfo.tokenAmount.value,
|
||||
)
|
||||
return provider.toContent(
|
||||
subtitle = stringReference(rateString),
|
||||
additionalBadge = resolveBadge(
|
||||
provider = provider,
|
||||
needApplyFCARestrictions = needApplyFCARestrictions,
|
||||
permissionState = permissionState,
|
||||
isBestRate = isBestRate,
|
||||
isNeedBestRateBadge = isNeedBestRateBadge,
|
||||
),
|
||||
additionalBadge = additionalBadge,
|
||||
selectionType = selectionType,
|
||||
percentLowerThenBest = PercentDifference.Empty,
|
||||
approvalSettings = ProviderState.ApprovalSettings.Empty,
|
||||
|
|
@ -71,30 +51,21 @@ internal object SwapProviderStateBuilder {
|
|||
@Suppress("LongParameterList")
|
||||
fun buildContentSelectable(
|
||||
provider: SwapProvider,
|
||||
toTokenInfo: TokenSwapInfo,
|
||||
permissionState: PermissionDataState,
|
||||
state: SwapState.QuotesLoadedState,
|
||||
pricesLowerBest: Map<String, Float>,
|
||||
selectionType: ProviderState.SelectionType,
|
||||
isBestRate: Boolean = false,
|
||||
isNeedBestRateBadge: Boolean = false,
|
||||
needApplyFCARestrictions: Boolean,
|
||||
additionalBadge: ProviderState.AdditionalBadge,
|
||||
onProviderClick: (String) -> Unit,
|
||||
onApprovalSelectClick: (SwapProvider) -> Unit = {},
|
||||
): ProviderState.Content {
|
||||
return provider.toContent(
|
||||
subtitle = buildSelectableSubtitle(toTokenInfo),
|
||||
additionalBadge = resolveBadge(
|
||||
provider = provider,
|
||||
needApplyFCARestrictions = needApplyFCARestrictions,
|
||||
permissionState = permissionState,
|
||||
isBestRate = isBestRate,
|
||||
isNeedBestRateBadge = isNeedBestRateBadge,
|
||||
),
|
||||
subtitle = buildSelectableSubtitle(state.toTokenInfo),
|
||||
additionalBadge = additionalBadge,
|
||||
selectionType = selectionType,
|
||||
percentLowerThenBest = pricesLowerBest[provider.providerId]
|
||||
?.let(PercentDifference::Value)
|
||||
?: PercentDifference.Value(0f),
|
||||
approvalSettings = when (permissionState) {
|
||||
approvalSettings = when (state.permissionState) {
|
||||
is PermissionDataState.PermissionSettings -> ProviderState.ApprovalSettings.Content(
|
||||
onApprovalSelectClick = { onApprovalSelectClick(provider) },
|
||||
)
|
||||
|
|
@ -112,15 +83,12 @@ internal object SwapProviderStateBuilder {
|
|||
provider: SwapProvider,
|
||||
alertText: TextReference,
|
||||
selectionType: ProviderState.SelectionType,
|
||||
needApplyFCARestrictions: Boolean,
|
||||
additionalBadge: ProviderState.AdditionalBadge,
|
||||
onProviderClick: (String) -> Unit,
|
||||
): ProviderState.Content {
|
||||
return provider.toContent(
|
||||
subtitle = alertText,
|
||||
additionalBadge = resolveBadge(
|
||||
provider = provider,
|
||||
needApplyFCARestrictions = needApplyFCARestrictions,
|
||||
),
|
||||
additionalBadge = additionalBadge,
|
||||
selectionType = selectionType,
|
||||
percentLowerThenBest = PercentDifference.Empty,
|
||||
approvalSettings = ProviderState.ApprovalSettings.Empty,
|
||||
|
|
@ -139,27 +107,6 @@ internal object SwapProviderStateBuilder {
|
|||
return stringReference(toAmount)
|
||||
}
|
||||
|
||||
private fun resolveBadge(
|
||||
provider: SwapProvider,
|
||||
needApplyFCARestrictions: Boolean,
|
||||
permissionState: PermissionDataState? = null,
|
||||
isBestRate: Boolean = false,
|
||||
isNeedBestRateBadge: Boolean = false,
|
||||
): ProviderState.AdditionalBadge {
|
||||
return when {
|
||||
needApplyFCARestrictions && provider.isFCARestricted() ->
|
||||
ProviderState.AdditionalBadge.FCAWarningList
|
||||
permissionState is PermissionDataState.PermissionRequired ->
|
||||
ProviderState.AdditionalBadge.PermissionRequired
|
||||
provider.isRecommended ->
|
||||
ProviderState.AdditionalBadge.Recommended
|
||||
isNeedBestRateBadge && isBestRate && !needApplyFCARestrictions ->
|
||||
ProviderState.AdditionalBadge.BestTrade
|
||||
else ->
|
||||
ProviderState.AdditionalBadge.Empty
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
private fun SwapProvider.toContent(
|
||||
subtitle: TextReference,
|
||||
|
|
@ -183,6 +130,4 @@ internal object SwapProviderStateBuilder {
|
|||
approvalSettings = approvalSettings,
|
||||
)
|
||||
}
|
||||
|
||||
private fun SwapProvider.isFCARestricted(): Boolean = providerId in FCA_RESTRICTED_PROVIDER_IDS
|
||||
}
|
||||
|
|
@ -47,6 +47,9 @@ internal class InitialCurrenciesResolver @Inject constructor(
|
|||
* @param userWalletId the wallet to resolve currencies for
|
||||
* @param initialCryptoCurrency pre-selected currency, or null to auto-select
|
||||
* @param swapCurrencyPosition preferred position for the initial currency
|
||||
* @param initialToCryptoCurrency optional currency to pre-select as TO. It is placed into the TO slot
|
||||
* ONLY if it already exists in the user's crypto portfolio (and the TO slot wasn't filled otherwise);
|
||||
* if the currency is not added to the wallet, the TO slot stays empty.
|
||||
* @return pair of (from, to) [SwapCurrencyStatus]; either or both may be null
|
||||
*/
|
||||
suspend operator fun invoke(
|
||||
|
|
@ -54,6 +57,7 @@ internal class InitialCurrenciesResolver @Inject constructor(
|
|||
initialCryptoCurrency: CryptoCurrency?,
|
||||
swapCurrencyPosition: CurrencyPosition,
|
||||
isPaymentAccount: Boolean,
|
||||
initialToCryptoCurrency: CryptoCurrency? = null,
|
||||
): Pair<SwapCurrencyStatus?, SwapCurrencyStatus?> {
|
||||
val walletAccountList = getWalletAccountCurrencyStatusList(userWalletId)
|
||||
val cryptoPortfolioAccounts = walletAccountList.filterKeys { accountStatus ->
|
||||
|
|
@ -65,7 +69,7 @@ internal class InitialCurrenciesResolver @Inject constructor(
|
|||
|
||||
val cryptoCurrencyList = cryptoPortfolioAccounts.values.flatten()
|
||||
|
||||
return if (initialCryptoCurrency != null) {
|
||||
val (from, to) = if (initialCryptoCurrency != null) {
|
||||
val selectedSwapCurrencyStatus = if (isPaymentAccount) {
|
||||
cryptoPaymentAccounts
|
||||
} else {
|
||||
|
|
@ -91,6 +95,43 @@ internal class InitialCurrenciesResolver @Inject constructor(
|
|||
cryptoCurrencyList = cryptoCurrencyList,
|
||||
) to null
|
||||
}
|
||||
|
||||
val resolvedTo = to ?: resolveExplicitToCurrency(
|
||||
initialToCryptoCurrency = initialToCryptoCurrency,
|
||||
from = from,
|
||||
cryptoPortfolioAccountsMap = cryptoPortfolioAccounts,
|
||||
)
|
||||
|
||||
return from to resolvedTo
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the optional explicit TO currency, but only if it is already present in the user's crypto
|
||||
* portfolio. Matches by token identity ([isSameTokenAs]) rather than full id, since the passed currency
|
||||
* may come from a different account/derivation. Prefers the instance from the FROM account, then falls
|
||||
* back to the first match across the portfolio. Never returns the same token as FROM.
|
||||
*/
|
||||
private fun resolveExplicitToCurrency(
|
||||
initialToCryptoCurrency: CryptoCurrency?,
|
||||
from: SwapCurrencyStatus?,
|
||||
cryptoPortfolioAccountsMap: Map<AccountStatus.CryptoPortfolio, List<SwapCurrencyStatus>>,
|
||||
): SwapCurrencyStatus? {
|
||||
if (initialToCryptoCurrency == null) return null
|
||||
|
||||
val fromCurrency = from?.currency
|
||||
fun matches(status: SwapCurrencyStatus): Boolean {
|
||||
return status.currency.isSameTokenAs(initialToCryptoCurrency) &&
|
||||
(fromCurrency == null || !status.currency.isSameTokenAs(fromCurrency))
|
||||
}
|
||||
|
||||
val fromAccountMatch = from?.account?.accountId?.let { fromAccountId ->
|
||||
cryptoPortfolioAccountsMap.entries
|
||||
.firstOrNull { (accountStatus, _) -> accountStatus.account.accountId == fromAccountId }
|
||||
?.value
|
||||
?.firstOrNull(::matches)
|
||||
}
|
||||
|
||||
return fromAccountMatch ?: cryptoPortfolioAccountsMap.values.flatten().firstOrNull(::matches)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ import com.tangem.core.decompose.model.ParamsContainer
|
|||
import com.tangem.core.decompose.navigation.Router
|
||||
import com.tangem.core.decompose.ui.UiMessageSender
|
||||
import com.tangem.core.navigation.url.UrlOpener
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.extensions.*
|
||||
import com.tangem.core.ui.format.bigdecimal.fiat
|
||||
import com.tangem.core.ui.format.bigdecimal.format
|
||||
|
|
@ -81,6 +80,7 @@ import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase
|
|||
import com.tangem.feature.swap.analytics.SwapEvents
|
||||
import com.tangem.feature.swap.analytics.SwapQuotePerformanceTracker
|
||||
import com.tangem.feature.swap.component.SwapFeeSelectorBlockComponent
|
||||
import com.tangem.feature.swap.converters.SwapProviderResolver
|
||||
import com.tangem.feature.swap.converters.SwapTransactionErrorStateConverter
|
||||
import com.tangem.feature.swap.domain.AllowPermissionsHandler
|
||||
import com.tangem.feature.swap.domain.GetSwapUiModeUseCase
|
||||
|
|
@ -108,12 +108,12 @@ import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenResult
|
|||
import com.tangem.features.send.api.entity.FeeItem
|
||||
import com.tangem.features.send.api.entity.FeeSelectorUM
|
||||
import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorReloadTrigger
|
||||
import com.tangem.features.send.impl.R
|
||||
import com.tangem.features.swap.SwapComponent
|
||||
import com.tangem.features.swap.SwapFeatureToggles
|
||||
import com.tangem.utils.Provider
|
||||
import com.tangem.utils.coroutines.*
|
||||
import com.tangem.utils.extensions.filterIf
|
||||
import com.tangem.utils.isNullOrZero
|
||||
import com.tangem.utils.logging.TangemLogger
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.delay
|
||||
|
|
@ -125,8 +125,6 @@ import java.math.RoundingMode
|
|||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
|
||||
typealias SuccessLoadedSwapData = Map<SwapProvider, SwapState.QuotesLoadedState>
|
||||
|
||||
@Suppress("LongParameterList", "LargeClass")
|
||||
@Stable
|
||||
@ModelScoped
|
||||
|
|
@ -172,7 +170,7 @@ internal class SwapModel @Inject constructor(
|
|||
|
||||
private val params = paramsContainer.require<SwapComponent.Params>()
|
||||
|
||||
private val initialCryptoCurrency = params.cryptoCurrency
|
||||
private val initialCryptoCurrency = params.fromCryptoCurrency
|
||||
private val tangemPayInput = params.tangemPayInput
|
||||
|
||||
private var isBalanceHidden = true
|
||||
|
|
@ -231,14 +229,6 @@ internal class SwapModel @Inject constructor(
|
|||
private val isFiatInput = mutableStateOf(false)
|
||||
private var userCountry: UserCountry? = null
|
||||
|
||||
private val isUserResolvableError: (SwapState) -> Boolean = { swapState ->
|
||||
swapState is SwapState.SwapError &&
|
||||
(
|
||||
swapState.error is ExpressDataError.ExchangeTooSmallAmountError ||
|
||||
swapState.error is ExpressDataError.ExchangeTooBigAmountError
|
||||
)
|
||||
}
|
||||
|
||||
private val fromTokenBalanceJobHolder = JobHolder()
|
||||
private val toTokenBalanceJobHolder = JobHolder()
|
||||
private val swapPairsJobHolder = JobHolder()
|
||||
|
|
@ -417,8 +407,9 @@ internal class SwapModel @Inject constructor(
|
|||
val (fromSwapCurrencyStatus, toSwapCurrencyStatus) = initialCurrenciesResolver(
|
||||
userWalletId = params.userWalletId,
|
||||
initialCryptoCurrency = initialCryptoCurrency,
|
||||
swapCurrencyPosition = params.currencyPosition,
|
||||
swapCurrencyPosition = params.fromCurrencyPosition,
|
||||
isPaymentAccount = params.tangemPayInput != null,
|
||||
initialToCryptoCurrency = params.toCryptoCurrency,
|
||||
)
|
||||
|
||||
preselectedFromCurrency = fromSwapCurrencyStatus?.currency
|
||||
|
|
@ -1043,7 +1034,7 @@ internal class SwapModel @Inject constructor(
|
|||
)
|
||||
}
|
||||
|
||||
val successStates = providersState.getLastLoadedSuccessStates()
|
||||
val successStates = dataState.getLastLoadedSuccessStates()
|
||||
val pricesLowerBest = getPricesLowerBest(provider.providerId, successStates)
|
||||
uiState = stateBuilder.updateProvidersBottomSheetContent(
|
||||
uiState = uiState,
|
||||
|
|
@ -1097,16 +1088,20 @@ internal class SwapModel @Inject constructor(
|
|||
}
|
||||
|
||||
private fun setupQuotesLoadedUiState(provider: SwapProvider, state: SwapState.QuotesLoadedState) {
|
||||
val loadedStates = dataState.lastLoadedSwapStates.getLastLoadedSuccessStates()
|
||||
val bestRatedProviderId = findBestQuoteProvider(loadedStates)?.providerId ?: provider.providerId
|
||||
val loadedStates = dataState.getLastLoadedSuccessStates()
|
||||
val additionalBadge = SwapProviderResolver.resolveBadge(
|
||||
provider = provider,
|
||||
needApplyFCARestrictions = userCountry.needApplyFCARestrictions(),
|
||||
states = loadedStates,
|
||||
state = state,
|
||||
isSwapBestDexRateEnabled = swapFeatureToggles.isSwapBestDexRateEnabled,
|
||||
)
|
||||
uiState = stateBuilder.createQuotesLoadedState(
|
||||
uiStateHolder = uiState,
|
||||
quoteModel = state,
|
||||
feeCryptoCurrencyStatus = dataState.feePaidCryptoCurrency,
|
||||
swapProvider = provider,
|
||||
bestRatedProviderId = bestRatedProviderId,
|
||||
isNeedBestRateBadge = dataState.lastLoadedSwapStates.consideredProvidersStates().size > 1,
|
||||
needApplyFCARestrictions = userCountry.needApplyFCARestrictions(),
|
||||
additionalBadge = additionalBadge,
|
||||
swapFee = getSelectedSwapFee(),
|
||||
feeError = feeSelectorRepository.state.value as? FeeSelectorUM.Error,
|
||||
)
|
||||
|
|
@ -1181,6 +1176,14 @@ internal class SwapModel @Inject constructor(
|
|||
|
||||
private fun setupErrorUiState(provider: SwapProvider, state: SwapState.SwapError) {
|
||||
singleTaskScheduler.cancelTask()
|
||||
val loadedStates = dataState.getLastLoadedSuccessStates()
|
||||
val additionalBadge = SwapProviderResolver.resolveBadge(
|
||||
provider = provider,
|
||||
needApplyFCARestrictions = userCountry.needApplyFCARestrictions(),
|
||||
states = loadedStates,
|
||||
state = state,
|
||||
isSwapBestDexRateEnabled = swapFeatureToggles.isSwapBestDexRateEnabled,
|
||||
)
|
||||
uiState = stateBuilder.createQuotesErrorState(
|
||||
uiStateHolder = uiState,
|
||||
swapProvider = provider,
|
||||
|
|
@ -1188,7 +1191,7 @@ internal class SwapModel @Inject constructor(
|
|||
toSwapCurrencyStatus = dataState.toSwapCurrencyStatus,
|
||||
expressDataError = state.error,
|
||||
balanceStatus = state.balanceStatus,
|
||||
needApplyFCARestrictions = userCountry.needApplyFCARestrictions(),
|
||||
additionalBadge = additionalBadge,
|
||||
swapFee = getSelectedSwapFee(),
|
||||
)
|
||||
sendErrorAnalyticsEvent(state.error, provider)
|
||||
|
|
@ -1234,7 +1237,10 @@ internal class SwapModel @Inject constructor(
|
|||
|
||||
return if (consideredProviders.isNotEmpty()) {
|
||||
val successLoadedData = consideredProviders.getLastLoadedSuccessStates()
|
||||
val bestQuotesProvider = findBestQuoteProvider(successLoadedData)
|
||||
val bestQuotesProvider = SwapProviderResolver.findBest(
|
||||
states = successLoadedData,
|
||||
isSwapBestDexRateEnabled = swapFeatureToggles.isSwapBestDexRateEnabled,
|
||||
)
|
||||
val currentSelected = dataState.selectedProvider
|
||||
if (currentSelected != null && consideredProviders.keys.contains(currentSelected)) {
|
||||
// logic for always choose best if already selected provider
|
||||
|
|
@ -1976,17 +1982,15 @@ internal class SwapModel @Inject constructor(
|
|||
onProviderClick = { providerId ->
|
||||
singleTaskScheduler.cancelTask()
|
||||
analyticsEventHandler.send(SwapEvents.ProviderClicked())
|
||||
val states = dataState.lastLoadedSwapStates.getLastLoadedSuccessStates()
|
||||
val states = dataState.getLastLoadedSuccessStates()
|
||||
val pricesLowerBest = getPricesLowerBest(providerId, states)
|
||||
val bestRatedProviderId = findBestQuoteProvider(states)?.providerId ?: providerId
|
||||
uiState = stateBuilder.showSelectProviderBottomSheet(
|
||||
uiState = uiState,
|
||||
selectedProviderId = providerId,
|
||||
pricesLowerBest = pricesLowerBest,
|
||||
providersStates = dataState.lastLoadedSwapStates,
|
||||
isSwapBestDexRateEnabled = swapFeatureToggles.isSwapBestDexRateEnabled,
|
||||
needApplyFCARestrictions = userCountry.needApplyFCARestrictions(),
|
||||
bestRatedProviderId = bestRatedProviderId,
|
||||
isNeedBestRateBadge = dataState.lastLoadedSwapStates.consideredProvidersStates().size > 1,
|
||||
) { uiState = stateBuilder.dismissBottomSheet(uiState) }
|
||||
},
|
||||
onProviderSelect = { providerId ->
|
||||
|
|
@ -1996,7 +2000,7 @@ internal class SwapModel @Inject constructor(
|
|||
val toSwapCurrencyStatus = dataState.toSwapCurrencyStatus
|
||||
val isNotNullCurrency = fromSwapCurrencyStatus != null && toSwapCurrencyStatus != null
|
||||
if (provider != null && swapState != null && isNotNullCurrency) {
|
||||
modelScope.launch {
|
||||
modelScope.launch(dispatchers.default) {
|
||||
feeSelectorRepository.state.value = FeeSelectorUM.Loading
|
||||
feeSelectorReloadTrigger.triggerUpdate()
|
||||
}
|
||||
|
|
@ -2156,24 +2160,6 @@ internal class SwapModel @Inject constructor(
|
|||
return selectedProvider
|
||||
}
|
||||
|
||||
private fun findBestQuoteProvider(state: SuccessLoadedSwapData): SwapProvider? {
|
||||
// finding best quotes
|
||||
return state.minByOrNull { entry ->
|
||||
val toTokenInfo = entry.value.toTokenInfo
|
||||
val fromAmountFiat = entry.value.fromTokenInfo.amountFiat
|
||||
val toAmountFiat = toTokenInfo.amountFiat
|
||||
if (!fromAmountFiat.isNullOrZero() && !toAmountFiat.isNullOrZero()) {
|
||||
fromAmountFiat.divide(
|
||||
toAmountFiat,
|
||||
toTokenInfo.swapCurrencyStatus.currency.decimals,
|
||||
RoundingMode.HALF_UP,
|
||||
)
|
||||
} else {
|
||||
BigDecimal.ZERO
|
||||
}
|
||||
}?.key
|
||||
}
|
||||
|
||||
private fun getPricesLowerBest(selectedProviderId: String, state: SuccessLoadedSwapData): Map<String, Float> {
|
||||
val selectedProviderEntry =
|
||||
state.filter { entry -> entry.key.providerId == selectedProviderId }.entries.firstOrNull()
|
||||
|
|
@ -2257,17 +2243,6 @@ internal class SwapModel @Inject constructor(
|
|||
)
|
||||
}
|
||||
|
||||
private fun Map<SwapProvider, SwapState>.getLastLoadedSuccessStates(): SuccessLoadedSwapData {
|
||||
return this.filter { entry -> entry.value is SwapState.QuotesLoadedState }
|
||||
.mapValues { entry -> entry.value as SwapState.QuotesLoadedState }
|
||||
}
|
||||
|
||||
private fun Map<SwapProvider, SwapState>.consideredProvidersStates(): Map<SwapProvider, SwapState> {
|
||||
return this.filter { entry ->
|
||||
entry.value is SwapState.QuotesLoadedState || isUserResolvableError(entry.value)
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendNoticePermissionNeededEvent() {
|
||||
val sendTokenSymbol = dataState.fromSwapCurrencyStatus?.currency?.symbol ?: return
|
||||
val receiveTokenSymbol = dataState.toSwapCurrencyStatus?.currency?.symbol ?: return
|
||||
|
|
@ -2342,7 +2317,7 @@ internal class SwapModel @Inject constructor(
|
|||
modelScope.launch {
|
||||
val transaction = dataState.getCurrentLoadedSwapState()?.swapDataModel?.transaction
|
||||
val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus
|
||||
val fromCurrency = fromSwapCurrencyStatus?.currency ?: params.cryptoCurrency
|
||||
val fromCurrency = fromSwapCurrencyStatus?.currency ?: params.fromCryptoCurrency
|
||||
val fromWalletId = fromSwapCurrencyStatus?.userWalletId ?: params.userWalletId
|
||||
val network = fromCurrency?.network
|
||||
val fee = getSelectedSwapFee()?.fee
|
||||
|
|
|
|||
|
|
@ -2,11 +2,14 @@ package com.tangem.feature.swap.model
|
|||
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.swap.models.SwapCurrencyStatus
|
||||
import com.tangem.feature.swap.domain.models.ExpressDataError
|
||||
import com.tangem.feature.swap.domain.models.domain.SwapPairLeast
|
||||
import com.tangem.feature.swap.domain.models.domain.SwapProvider
|
||||
import com.tangem.feature.swap.domain.models.ui.SwapState
|
||||
import java.math.BigDecimal
|
||||
|
||||
typealias SuccessLoadedSwapData = Map<SwapProvider, SwapState.QuotesLoadedState>
|
||||
|
||||
data class SwapProcessDataState(
|
||||
// Initial network id
|
||||
val fromSwapCurrencyStatus: SwapCurrencyStatus? = null,
|
||||
|
|
@ -30,4 +33,28 @@ data class SwapProcessDataState(
|
|||
fun getCurrentLoadedSwapState(): SwapState.QuotesLoadedState? {
|
||||
return lastLoadedSwapStates[selectedProvider] as? SwapState.QuotesLoadedState
|
||||
}
|
||||
|
||||
fun getLastLoadedSuccessStates(): SuccessLoadedSwapData {
|
||||
return lastLoadedSwapStates.filter { entry -> entry.value is SwapState.QuotesLoadedState }
|
||||
.mapValues { entry -> entry.value as SwapState.QuotesLoadedState }
|
||||
}
|
||||
}
|
||||
|
||||
internal fun Map<SwapProvider, SwapState>.getLastLoadedSuccessStates(): SuccessLoadedSwapData {
|
||||
return this.filter { entry -> entry.value is SwapState.QuotesLoadedState }
|
||||
.mapValues { entry -> entry.value as SwapState.QuotesLoadedState }
|
||||
}
|
||||
|
||||
internal fun Map<SwapProvider, SwapState>.consideredProvidersStates(): Map<SwapProvider, SwapState> {
|
||||
fun isUserResolvableError(swapState: SwapState): Boolean {
|
||||
return swapState is SwapState.SwapError &&
|
||||
(
|
||||
swapState.error is ExpressDataError.ExchangeTooSmallAmountError ||
|
||||
swapState.error is ExpressDataError.ExchangeTooBigAmountError
|
||||
)
|
||||
}
|
||||
|
||||
return this.filter { entry ->
|
||||
entry.value is SwapState.QuotesLoadedState || isUserResolvableError(entry.value)
|
||||
}
|
||||
}
|
||||
|
|
@ -47,6 +47,7 @@ sealed class ProviderState {
|
|||
sealed class AdditionalBadge {
|
||||
data object FCAWarningList : AdditionalBadge()
|
||||
data object BestTrade : AdditionalBadge()
|
||||
data object BestDexRate : AdditionalBadge()
|
||||
data object Empty : AdditionalBadge()
|
||||
data object PermissionRequired : AdditionalBadge()
|
||||
data object Recommended : AdditionalBadge()
|
||||
|
|
|
|||
|
|
@ -160,6 +160,7 @@ private fun ProviderContentState(
|
|||
when (state.additionalBadge) {
|
||||
ProviderState.AdditionalBadge.FCAWarningList -> FCABadgeItem(badgeModifier)
|
||||
ProviderState.AdditionalBadge.BestTrade -> BestTradeItem(badgeModifier)
|
||||
ProviderState.AdditionalBadge.BestDexRate -> BestDexRateItem(badgeModifier)
|
||||
ProviderState.AdditionalBadge.PermissionRequired -> PermissionBadgeItem(badgeModifier)
|
||||
ProviderState.AdditionalBadge.Recommended -> RecommendedItem(badgeModifier)
|
||||
ProviderState.AdditionalBadge.Empty -> Unit
|
||||
|
|
@ -403,6 +404,24 @@ private fun BestTradeItem(modifier: Modifier = Modifier) {
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BestDexRateItem(modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
modifier = modifier.background(
|
||||
color = TangemTheme.colors.icon.accent.copy(alpha = 0.1f),
|
||||
shape = TangemTheme.shapes.roundedCornersLarge,
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
text = stringResourceSafe(R.string.express_provider_best_dex_rate),
|
||||
style = TangemTheme.typography.caption1,
|
||||
color = TangemTheme.colors.icon.accent,
|
||||
modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing6),
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PermissionBadgeItem(modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
|
|
|
|||
|
|
@ -92,7 +92,9 @@ private fun SimpleProviderTrailing(state: ProviderState) {
|
|||
.size(TangemTheme.dimens.size20)
|
||||
.clip(RoundedCornerShape(TangemTheme.dimens.radius4)),
|
||||
)
|
||||
if (state.additionalBadge is ProviderState.AdditionalBadge.BestTrade) {
|
||||
if (state.additionalBadge is ProviderState.AdditionalBadge.BestTrade ||
|
||||
state.additionalBadge is ProviderState.AdditionalBadge.BestDexRate
|
||||
) {
|
||||
SimpleBestRateBadge(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import com.tangem.domain.swap.models.SwapCurrencyStatus
|
|||
import com.tangem.domain.tokens.model.Amount
|
||||
import com.tangem.domain.transaction.error.GetFeeError
|
||||
import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork
|
||||
import com.tangem.feature.swap.converters.SwapProviderResolver
|
||||
import com.tangem.feature.swap.converters.SwapProviderStateBuilder
|
||||
import com.tangem.feature.swap.domain.models.ExpressDataError
|
||||
import com.tangem.feature.swap.domain.models.SwapAmount
|
||||
|
|
@ -43,6 +44,7 @@ import com.tangem.feature.swap.domain.models.domain.*
|
|||
import com.tangem.feature.swap.domain.models.ui.*
|
||||
import com.tangem.feature.swap.model.SwapNotificationsFactory
|
||||
import com.tangem.feature.swap.model.SwapProcessDataState
|
||||
import com.tangem.feature.swap.model.getLastLoadedSuccessStates
|
||||
import com.tangem.feature.swap.models.*
|
||||
import com.tangem.feature.swap.models.SwapButton.Mode
|
||||
import com.tangem.feature.swap.models.states.*
|
||||
|
|
@ -529,9 +531,7 @@ internal class StateBuilder(
|
|||
quoteModel: SwapState.QuotesLoadedState,
|
||||
feeCryptoCurrencyStatus: CryptoCurrencyStatus?,
|
||||
swapProvider: SwapProvider,
|
||||
bestRatedProviderId: String,
|
||||
isNeedBestRateBadge: Boolean,
|
||||
needApplyFCARestrictions: Boolean,
|
||||
additionalBadge: ProviderState.AdditionalBadge,
|
||||
swapFee: SwapFee?,
|
||||
feeError: FeeSelectorUM.Error?,
|
||||
): SwapStateHolder {
|
||||
|
|
@ -641,13 +641,9 @@ internal class StateBuilder(
|
|||
changeCardsButtonState = ChangeCardsButtonState.ENABLED,
|
||||
providerState = SwapProviderStateBuilder.buildContentClickable(
|
||||
provider = swapProvider,
|
||||
fromTokenInfo = quoteModel.fromTokenInfo,
|
||||
toTokenInfo = quoteModel.toTokenInfo,
|
||||
permissionState = quoteModel.permissionState,
|
||||
state = quoteModel,
|
||||
selectionType = ProviderState.SelectionType.CLICK,
|
||||
isBestRate = bestRatedProviderId == swapProvider.providerId && !priceImpact.shouldShowWarning(),
|
||||
isNeedBestRateBadge = isNeedBestRateBadge,
|
||||
needApplyFCARestrictions = needApplyFCARestrictions,
|
||||
additionalBadge = additionalBadge,
|
||||
onProviderClick = actions.onProviderClick,
|
||||
),
|
||||
priceImpact = priceImpact,
|
||||
|
|
@ -769,7 +765,7 @@ internal class StateBuilder(
|
|||
toSwapCurrencyStatus: SwapCurrencyStatus?,
|
||||
balanceStatus: SwapBalanceStatus,
|
||||
expressDataError: ExpressDataError,
|
||||
needApplyFCARestrictions: Boolean,
|
||||
additionalBadge: ProviderState.AdditionalBadge,
|
||||
swapFee: SwapFee?,
|
||||
): SwapStateHolder {
|
||||
if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder
|
||||
|
|
@ -789,7 +785,7 @@ internal class StateBuilder(
|
|||
expressDataError = expressDataError,
|
||||
onProviderClick = actions.onProviderClick,
|
||||
selectionType = ProviderState.SelectionType.CLICK,
|
||||
needApplyFCARestrictions = needApplyFCARestrictions,
|
||||
additionalBadge = additionalBadge,
|
||||
)
|
||||
val type = TransactionCardType.ReadOnly(
|
||||
accountTitleUM = getCardAccountTitle(
|
||||
|
|
@ -837,7 +833,7 @@ internal class StateBuilder(
|
|||
expressDataError: ExpressDataError,
|
||||
onProviderClick: (String) -> Unit,
|
||||
selectionType: ProviderState.SelectionType,
|
||||
needApplyFCARestrictions: Boolean,
|
||||
additionalBadge: ProviderState.AdditionalBadge,
|
||||
): ProviderState {
|
||||
return when (expressDataError) {
|
||||
is ExpressDataError.ExchangeTooSmallAmountError -> {
|
||||
|
|
@ -848,7 +844,7 @@ internal class StateBuilder(
|
|||
wrappedList(expressDataError.amount.getFormattedCryptoAmount(fromToken)),
|
||||
),
|
||||
selectionType = selectionType,
|
||||
needApplyFCARestrictions = needApplyFCARestrictions,
|
||||
additionalBadge = additionalBadge,
|
||||
onProviderClick = onProviderClick,
|
||||
)
|
||||
}
|
||||
|
|
@ -860,7 +856,7 @@ internal class StateBuilder(
|
|||
wrappedList(expressDataError.amount.getFormattedCryptoAmount(fromToken)),
|
||||
),
|
||||
selectionType = selectionType,
|
||||
needApplyFCARestrictions = needApplyFCARestrictions,
|
||||
additionalBadge = additionalBadge,
|
||||
onProviderClick = onProviderClick,
|
||||
)
|
||||
}
|
||||
|
|
@ -1177,19 +1173,24 @@ internal class StateBuilder(
|
|||
pricesLowerBest: Map<String, Float>,
|
||||
providersStates: Map<SwapProvider, SwapState>,
|
||||
needApplyFCARestrictions: Boolean,
|
||||
bestRatedProviderId: String,
|
||||
isNeedBestRateBadge: Boolean,
|
||||
isSwapBestDexRateEnabled: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
): SwapStateHolder {
|
||||
val successStates = providersStates.getLastLoadedSuccessStates()
|
||||
val availableProvidersStates = providersStates.entries
|
||||
.mapNotNull { entry ->
|
||||
val additionalBadge = SwapProviderResolver.resolveBadge(
|
||||
states = successStates,
|
||||
provider = entry.key,
|
||||
needApplyFCARestrictions = needApplyFCARestrictions,
|
||||
state = entry.value,
|
||||
isSwapBestDexRateEnabled = isSwapBestDexRateEnabled,
|
||||
)
|
||||
entry.convertToProviderBottomSheetState(
|
||||
pricesLowerBest = pricesLowerBest,
|
||||
onProviderSelect = actions.onProviderSelect,
|
||||
needApplyFCARestrictions = needApplyFCARestrictions,
|
||||
onApprovalSelectClick = actions.onApproveTypeSelect,
|
||||
bestRatedProviderId = bestRatedProviderId,
|
||||
isNeedBestRateBadge = isNeedBestRateBadge,
|
||||
additionalBadge = additionalBadge,
|
||||
)
|
||||
}
|
||||
.sortedWith(ProviderPercentDiffComparator)
|
||||
|
|
@ -1294,23 +1295,18 @@ internal class StateBuilder(
|
|||
pricesLowerBest: Map<String, Float>,
|
||||
onProviderSelect: (String) -> Unit,
|
||||
onApprovalSelectClick: (SwapProvider) -> Unit,
|
||||
needApplyFCARestrictions: Boolean,
|
||||
bestRatedProviderId: String,
|
||||
isNeedBestRateBadge: Boolean,
|
||||
additionalBadge: ProviderState.AdditionalBadge,
|
||||
): ProviderState? {
|
||||
val provider = this.key
|
||||
return when (val state = this.value) {
|
||||
val (provider, state) = this
|
||||
return when (state) {
|
||||
is SwapState.EmptyAmountState, is SwapState.Transfer -> null
|
||||
is SwapState.QuotesLoadedState -> {
|
||||
SwapProviderStateBuilder.buildContentSelectable(
|
||||
provider = provider,
|
||||
toTokenInfo = state.toTokenInfo,
|
||||
permissionState = state.permissionState,
|
||||
state = state,
|
||||
pricesLowerBest = pricesLowerBest,
|
||||
selectionType = ProviderState.SelectionType.SELECT,
|
||||
needApplyFCARestrictions = needApplyFCARestrictions,
|
||||
isBestRate = bestRatedProviderId == provider.providerId && !state.priceImpact.shouldShowWarning(),
|
||||
isNeedBestRateBadge = isNeedBestRateBadge,
|
||||
additionalBadge = additionalBadge,
|
||||
onProviderClick = onProviderSelect,
|
||||
onApprovalSelectClick = onApprovalSelectClick,
|
||||
)
|
||||
|
|
@ -1321,7 +1317,7 @@ internal class StateBuilder(
|
|||
expressDataError = state.error,
|
||||
onProviderClick = onProviderSelect,
|
||||
selectionType = ProviderState.SelectionType.SELECT,
|
||||
needApplyFCARestrictions = needApplyFCARestrictions,
|
||||
additionalBadge = additionalBadge,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1151,6 +1151,161 @@ internal class DefaultInitialCurrenciesResolverTest {
|
|||
|
||||
// endregion
|
||||
|
||||
// region explicit TO currency
|
||||
|
||||
@Test
|
||||
fun `GIVEN explicit TO currency present in portfolio WHEN invoke with position FROM THEN it is placed as TO`() =
|
||||
runTest {
|
||||
val fromId = mockk<CryptoCurrency.ID>(relaxed = true)
|
||||
val initialFrom = mockCryptoCurrency(id = fromId)
|
||||
val accountFrom = mockCryptoCurrency(id = fromId)
|
||||
|
||||
// Same token (network + contract), different id instance from the passed one.
|
||||
val accountTo = mockCryptoCurrency(id = mockCurrencyId("ethereum", "0xUSDT"))
|
||||
val explicitTo = mockCryptoCurrency(id = mockCurrencyId("ethereum", "0xUSDT"))
|
||||
|
||||
val fromStatus = createCurrencyStatus(accountFrom, fiatAmount = BigDecimal("100"))
|
||||
val toStatus = createCurrencyStatus(accountTo, fiatAmount = BigDecimal("50"))
|
||||
val accountStatus = createCryptoPortfolioAccountStatus(listOf(fromStatus, toStatus))
|
||||
setupSupplier(listOf(accountStatus))
|
||||
setupAvailability(linkedMapOf(accountFrom to true, accountTo to true))
|
||||
|
||||
val (from, to) = resolver.invoke(
|
||||
userWalletId,
|
||||
initialCryptoCurrency = initialFrom,
|
||||
swapCurrencyPosition = CurrencyPosition.FROM,
|
||||
isPaymentAccount = false,
|
||||
initialToCryptoCurrency = explicitTo,
|
||||
)
|
||||
|
||||
assertThat(from?.status).isSameInstanceAs(fromStatus)
|
||||
assertThat(to?.status).isSameInstanceAs(toStatus)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN explicit TO currency not present in portfolio WHEN invoke THEN TO stays null`() = runTest {
|
||||
val fromId = mockk<CryptoCurrency.ID>(relaxed = true)
|
||||
val initialFrom = mockCryptoCurrency(id = fromId)
|
||||
val accountFrom = mockCryptoCurrency(id = fromId)
|
||||
|
||||
val explicitTo = mockCryptoCurrency(id = mockCurrencyId("ethereum", "0xNOT_IN_WALLET"))
|
||||
|
||||
val fromStatus = createCurrencyStatus(accountFrom, fiatAmount = BigDecimal("100"))
|
||||
val accountStatus = createCryptoPortfolioAccountStatus(listOf(fromStatus))
|
||||
setupSupplier(listOf(accountStatus))
|
||||
setupAvailability(linkedMapOf(accountFrom to true))
|
||||
|
||||
val (from, to) = resolver.invoke(
|
||||
userWalletId,
|
||||
initialCryptoCurrency = initialFrom,
|
||||
swapCurrencyPosition = CurrencyPosition.FROM,
|
||||
isPaymentAccount = false,
|
||||
initialToCryptoCurrency = explicitTo,
|
||||
)
|
||||
|
||||
assertThat(from?.status).isSameInstanceAs(fromStatus)
|
||||
assertThat(to).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN explicit TO currency is the same token as FROM WHEN invoke THEN TO stays null`() = runTest {
|
||||
val sharedId = mockCurrencyId("ethereum", "0xUSDT")
|
||||
val initialFrom = mockCryptoCurrency(id = sharedId)
|
||||
val accountFrom = mockCryptoCurrency(id = sharedId)
|
||||
// Passed TO is the same token as FROM (different id instance, same network + contract).
|
||||
val explicitTo = mockCryptoCurrency(id = mockCurrencyId("ethereum", "0xUSDT"))
|
||||
|
||||
val fromStatus = createCurrencyStatus(accountFrom, fiatAmount = BigDecimal("100"))
|
||||
val accountStatus = createCryptoPortfolioAccountStatus(listOf(fromStatus))
|
||||
setupSupplier(listOf(accountStatus))
|
||||
setupAvailability(linkedMapOf(accountFrom to true))
|
||||
|
||||
val (from, to) = resolver.invoke(
|
||||
userWalletId,
|
||||
initialCryptoCurrency = initialFrom,
|
||||
swapCurrencyPosition = CurrencyPosition.FROM,
|
||||
isPaymentAccount = false,
|
||||
initialToCryptoCurrency = explicitTo,
|
||||
)
|
||||
|
||||
assertThat(from?.status).isSameInstanceAs(fromStatus)
|
||||
assertThat(to).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN TO slot already filled by position TO WHEN explicit TO provided THEN explicit TO is ignored`() =
|
||||
runTest {
|
||||
val sharedId = mockk<CryptoCurrency.ID>(relaxed = true)
|
||||
val initialCurrency = mockCryptoCurrency(id = sharedId)
|
||||
val accountCurrency = mockCryptoCurrency(id = sharedId)
|
||||
|
||||
// A different token that exists in the portfolio and would match the explicit TO.
|
||||
val otherTo = mockCryptoCurrency(id = mockCurrencyId("ethereum", "0xUSDT"))
|
||||
val explicitTo = mockCryptoCurrency(id = mockCurrencyId("ethereum", "0xUSDT"))
|
||||
|
||||
val status = createCurrencyStatus(accountCurrency, fiatAmount = BigDecimal("100"))
|
||||
val otherStatus = createCurrencyStatus(otherTo, fiatAmount = BigDecimal("50"))
|
||||
val accountStatus = createCryptoPortfolioAccountStatus(listOf(status, otherStatus))
|
||||
setupSupplier(listOf(accountStatus))
|
||||
setupAvailability(linkedMapOf(accountCurrency to true, otherTo to true))
|
||||
|
||||
val (from, to) = resolver.invoke(
|
||||
userWalletId,
|
||||
initialCryptoCurrency = initialCurrency,
|
||||
swapCurrencyPosition = CurrencyPosition.TO,
|
||||
isPaymentAccount = false,
|
||||
initialToCryptoCurrency = explicitTo,
|
||||
)
|
||||
|
||||
// Position TO already placed the selected currency in TO; explicit TO must not override it.
|
||||
assertThat(from).isNull()
|
||||
assertThat(to?.status).isSameInstanceAs(status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN explicit TO currency exists in multiple accounts WHEN invoke THEN instance from FROM account is used`() =
|
||||
runTest {
|
||||
// FROM lives in account 1.
|
||||
val fromId = mockk<CryptoCurrency.ID>(relaxed = true)
|
||||
val initialFrom = mockCryptoCurrency(id = fromId)
|
||||
val accountFrom = mockCryptoCurrency(id = fromId)
|
||||
|
||||
// Same TO token present in both accounts (different id instances).
|
||||
val toInAccount1 = mockCryptoCurrency(id = mockCurrencyId("ethereum", "0xUSDT"))
|
||||
val toInAccount2 = mockCryptoCurrency(id = mockCurrencyId("ethereum", "0xUSDT"))
|
||||
val explicitTo = mockCryptoCurrency(id = mockCurrencyId("ethereum", "0xUSDT"))
|
||||
|
||||
val fromStatus = createCurrencyStatus(accountFrom, fiatAmount = BigDecimal("100"))
|
||||
val toInAccount1Status = createCurrencyStatus(toInAccount1, fiatAmount = BigDecimal("10"))
|
||||
val toInAccount2Status = createCurrencyStatus(toInAccount2, fiatAmount = BigDecimal("5000"))
|
||||
|
||||
val account1 = createCryptoPortfolioAccountStatus(
|
||||
currencies = listOf(fromStatus, toInAccount1Status),
|
||||
derivationIndexValue = 0,
|
||||
)
|
||||
val account2 = createCryptoPortfolioAccountStatus(
|
||||
currencies = listOf(toInAccount2Status),
|
||||
derivationIndexValue = 1,
|
||||
)
|
||||
setupSupplier(listOf(account1, account2))
|
||||
setupAvailability(linkedMapOf(accountFrom to true, toInAccount1 to true))
|
||||
setupAvailability(linkedMapOf(toInAccount2 to true))
|
||||
|
||||
val (from, to) = resolver.invoke(
|
||||
userWalletId,
|
||||
initialCryptoCurrency = initialFrom,
|
||||
swapCurrencyPosition = CurrencyPosition.FROM,
|
||||
isPaymentAccount = false,
|
||||
initialToCryptoCurrency = explicitTo,
|
||||
)
|
||||
|
||||
// TO must be the instance from the FROM account, not the higher-balance duplicate in account 2.
|
||||
assertThat(from?.status).isSameInstanceAs(fromStatus)
|
||||
assertThat(to?.status).isSameInstanceAs(toInAccount1Status)
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region helpers
|
||||
|
||||
private fun mockCryptoCurrency(
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import com.tangem.feature.swap.domain.models.SwapAmount
|
|||
import com.tangem.feature.swap.domain.models.domain.*
|
||||
import com.tangem.feature.swap.domain.models.ui.*
|
||||
import com.tangem.feature.swap.models.*
|
||||
import com.tangem.feature.swap.models.states.ProviderState
|
||||
import com.tangem.feature.swap.ui.StateBuilder
|
||||
import com.tangem.features.swap.SwapFeatureToggles
|
||||
import com.tangem.utils.Provider
|
||||
|
|
@ -105,9 +106,7 @@ internal class StateBuilderSwapButtonTest {
|
|||
quoteModel = state,
|
||||
feeCryptoCurrencyStatus = null,
|
||||
swapProvider = buildProvider(ExchangeProviderType.CEX),
|
||||
bestRatedProviderId = "p",
|
||||
isNeedBestRateBadge = false,
|
||||
needApplyFCARestrictions = false,
|
||||
additionalBadge = ProviderState.AdditionalBadge.Empty,
|
||||
swapFee = null,
|
||||
feeError = null,
|
||||
)
|
||||
|
|
@ -132,9 +131,7 @@ internal class StateBuilderSwapButtonTest {
|
|||
quoteModel = state,
|
||||
feeCryptoCurrencyStatus = null,
|
||||
swapProvider = buildProvider(ExchangeProviderType.CEX),
|
||||
bestRatedProviderId = "p",
|
||||
isNeedBestRateBadge = false,
|
||||
needApplyFCARestrictions = false,
|
||||
additionalBadge = ProviderState.AdditionalBadge.Empty,
|
||||
swapFee = null,
|
||||
feeError = null,
|
||||
)
|
||||
|
|
@ -163,9 +160,7 @@ internal class StateBuilderSwapButtonTest {
|
|||
quoteModel = state,
|
||||
feeCryptoCurrencyStatus = null,
|
||||
swapProvider = buildProvider(ExchangeProviderType.CEX),
|
||||
bestRatedProviderId = "p",
|
||||
isNeedBestRateBadge = false,
|
||||
needApplyFCARestrictions = false,
|
||||
additionalBadge = ProviderState.AdditionalBadge.Empty,
|
||||
swapFee = null,
|
||||
feeError = null,
|
||||
)
|
||||
|
|
@ -189,9 +184,7 @@ internal class StateBuilderSwapButtonTest {
|
|||
quoteModel = state,
|
||||
feeCryptoCurrencyStatus = null,
|
||||
swapProvider = buildProvider(ExchangeProviderType.CEX),
|
||||
bestRatedProviderId = "p",
|
||||
isNeedBestRateBadge = false,
|
||||
needApplyFCARestrictions = false,
|
||||
additionalBadge = ProviderState.AdditionalBadge.Empty,
|
||||
swapFee = buildSwapFee(),
|
||||
feeError = null,
|
||||
)
|
||||
|
|
@ -219,9 +212,7 @@ internal class StateBuilderSwapButtonTest {
|
|||
quoteModel = state,
|
||||
feeCryptoCurrencyStatus = null,
|
||||
swapProvider = buildProvider(ExchangeProviderType.CEX),
|
||||
bestRatedProviderId = "p",
|
||||
isNeedBestRateBadge = false,
|
||||
needApplyFCARestrictions = false,
|
||||
additionalBadge = ProviderState.AdditionalBadge.Empty,
|
||||
swapFee = buildSwapFee(),
|
||||
feeError = null,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,523 @@
|
|||
package com.tangem.feature.swap.converters
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.swap.models.SwapCurrencyStatus
|
||||
import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType
|
||||
import com.tangem.feature.swap.domain.models.domain.SwapProvider
|
||||
import com.tangem.feature.swap.domain.models.ui.PermissionDataState
|
||||
import com.tangem.feature.swap.domain.models.ui.PriceImpact
|
||||
import com.tangem.feature.swap.domain.models.ui.SwapState
|
||||
import com.tangem.feature.swap.domain.models.ui.TokenSwapInfo
|
||||
import com.tangem.feature.swap.models.states.ProviderState
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
* Tests for [SwapProviderResolver] — best-provider selection ([SwapProviderResolver.findBest]) and
|
||||
* row-badge resolution ([SwapProviderResolver.resolveBadge]).
|
||||
*
|
||||
* Ranking metric: best provider == lowest `from/to` fiat ratio == highest `to` fiat output for the
|
||||
* same `from` input. "Best DEX Rate" prefers the best-rated DEX/DEX_BRIDGE provider when the feature
|
||||
* is on and any DEX is present.
|
||||
*/
|
||||
internal class SwapProviderResolverTest {
|
||||
|
||||
private val cex1 = provider(id = "cex1", type = ExchangeProviderType.CEX)
|
||||
private val cex2 = provider(id = "cex2", type = ExchangeProviderType.CEX)
|
||||
private val dex1 = provider(id = "dex1", type = ExchangeProviderType.DEX)
|
||||
private val dexBridge = provider(id = "dexBridge", type = ExchangeProviderType.DEX_BRIDGE)
|
||||
|
||||
/** Mirror of the provider ids the resolver treats as FCA restricted. */
|
||||
private val fcaRestrictedProviderIds = setOf(
|
||||
"changelly",
|
||||
"changenow",
|
||||
"okx-cross-chain",
|
||||
"okx-on-chain",
|
||||
"simpleswap",
|
||||
)
|
||||
|
||||
// region findBest
|
||||
|
||||
@Test
|
||||
fun `GIVEN best dex rate on AND a DEX present WHEN findBest THEN best DEX is selected`() {
|
||||
// CEX has the best overall rate (highest output), but a DEX is present.
|
||||
val states = mapOf(
|
||||
cex1 to quote(fromFiat = "100", toFiat = "120"), // best overall
|
||||
dex1 to quote(fromFiat = "100", toFiat = "110"), // best among DEX
|
||||
dexBridge to quote(fromFiat = "100", toFiat = "105"),
|
||||
)
|
||||
|
||||
val best = SwapProviderResolver.findBest(states, isSwapBestDexRateEnabled = true)
|
||||
|
||||
assertThat(best).isEqualTo(dex1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN best dex rate on AND no DEX present WHEN findBest THEN best CEX fallback`() {
|
||||
val states = mapOf(
|
||||
cex1 to quote(fromFiat = "100", toFiat = "110"),
|
||||
cex2 to quote(fromFiat = "100", toFiat = "120"), // best CEX
|
||||
)
|
||||
|
||||
val best = SwapProviderResolver.findBest(states, isSwapBestDexRateEnabled = true)
|
||||
|
||||
assertThat(best).isEqualTo(cex2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN best dex rate off WHEN findBest THEN best overall regardless of type`() {
|
||||
val states = mapOf(
|
||||
cex1 to quote(fromFiat = "100", toFiat = "120"), // best overall (a CEX)
|
||||
dex1 to quote(fromFiat = "100", toFiat = "110"),
|
||||
)
|
||||
|
||||
val best = SwapProviderResolver.findBest(states, isSwapBestDexRateEnabled = false)
|
||||
|
||||
assertThat(best).isEqualTo(cex1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN DEX_BRIDGE is the best DEX WHEN findBest with best dex rate on THEN DEX_BRIDGE selected`() {
|
||||
val states = mapOf(
|
||||
cex1 to quote(fromFiat = "100", toFiat = "130"),
|
||||
dex1 to quote(fromFiat = "100", toFiat = "108"),
|
||||
dexBridge to quote(fromFiat = "100", toFiat = "115"), // best among DEX-based
|
||||
)
|
||||
|
||||
val best = SwapProviderResolver.findBest(states, isSwapBestDexRateEnabled = true)
|
||||
|
||||
assertThat(best).isEqualTo(dexBridge)
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region resolveBadge
|
||||
|
||||
// --- Both CEX + DEX present, a DEX is the overall best → only "Best rate" (BestTrade) on that DEX.
|
||||
|
||||
@Test
|
||||
fun `GIVEN both types present AND DEX is overall best WHEN resolveBadge for that DEX THEN BestTrade`() {
|
||||
val states = mapOf(
|
||||
dex1 to quote(fromFiat = "100", toFiat = "120"), // best overall AND best DEX
|
||||
cex1 to quote(fromFiat = "100", toFiat = "110"),
|
||||
)
|
||||
|
||||
val badge = SwapProviderResolver.resolveBadge(
|
||||
states = states,
|
||||
provider = dex1,
|
||||
needApplyFCARestrictions = false,
|
||||
state = states.getValue(dex1),
|
||||
isSwapBestDexRateEnabled = true,
|
||||
)
|
||||
|
||||
// Overall best is the DEX → it gets the single "Best rate" badge, NOT "Best DEX rate".
|
||||
assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.BestTrade)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN both types present AND DEX is overall best WHEN resolveBadge for the CEX THEN Empty`() {
|
||||
val states = mapOf(
|
||||
dex1 to quote(fromFiat = "100", toFiat = "120"), // best overall
|
||||
cex1 to quote(fromFiat = "100", toFiat = "110"),
|
||||
)
|
||||
|
||||
val badge = SwapProviderResolver.resolveBadge(
|
||||
states = states,
|
||||
provider = cex1,
|
||||
needApplyFCARestrictions = false,
|
||||
state = states.getValue(cex1),
|
||||
isSwapBestDexRateEnabled = true,
|
||||
)
|
||||
|
||||
assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.Empty)
|
||||
}
|
||||
|
||||
// --- Both CEX + DEX present, a CEX is the overall best → BestTrade on the CEX, BestDexRate on the best DEX.
|
||||
|
||||
@Test
|
||||
fun `GIVEN both types present AND CEX is overall best WHEN resolveBadge for the CEX THEN BestTrade`() {
|
||||
val states = mapOf(
|
||||
cex1 to quote(fromFiat = "100", toFiat = "120"), // best overall (a CEX)
|
||||
dex1 to quote(fromFiat = "100", toFiat = "110"), // best DEX
|
||||
dexBridge to quote(fromFiat = "100", toFiat = "105"),
|
||||
)
|
||||
|
||||
val badge = SwapProviderResolver.resolveBadge(
|
||||
states = states,
|
||||
provider = cex1,
|
||||
needApplyFCARestrictions = false,
|
||||
state = states.getValue(cex1),
|
||||
isSwapBestDexRateEnabled = true,
|
||||
)
|
||||
|
||||
assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.BestTrade)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN both types present AND CEX is overall best WHEN resolveBadge for the best DEX THEN BestDexRate`() {
|
||||
val states = mapOf(
|
||||
cex1 to quote(fromFiat = "100", toFiat = "120"), // best overall (a CEX)
|
||||
dex1 to quote(fromFiat = "100", toFiat = "110"), // best DEX
|
||||
dexBridge to quote(fromFiat = "100", toFiat = "105"),
|
||||
)
|
||||
|
||||
val badge = SwapProviderResolver.resolveBadge(
|
||||
states = states,
|
||||
provider = dex1,
|
||||
needApplyFCARestrictions = false,
|
||||
state = states.getValue(dex1),
|
||||
isSwapBestDexRateEnabled = true,
|
||||
)
|
||||
|
||||
// CEX wins overall, so the best DEX additionally gets the "Best DEX rate" badge.
|
||||
assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.BestDexRate)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN both types present AND CEX is overall best WHEN resolveBadge for a non-best DEX THEN Empty`() {
|
||||
val states = mapOf(
|
||||
cex1 to quote(fromFiat = "100", toFiat = "120"), // best overall
|
||||
dex1 to quote(fromFiat = "100", toFiat = "110"), // best DEX
|
||||
dexBridge to quote(fromFiat = "100", toFiat = "105"), // not the best DEX
|
||||
)
|
||||
|
||||
val badge = SwapProviderResolver.resolveBadge(
|
||||
states = states,
|
||||
provider = dexBridge,
|
||||
needApplyFCARestrictions = false,
|
||||
state = states.getValue(dexBridge),
|
||||
isSwapBestDexRateEnabled = true,
|
||||
)
|
||||
|
||||
assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.Empty)
|
||||
}
|
||||
|
||||
// --- DEX-only → only "Best rate" (BestTrade) on the best DEX; no separate "Best DEX rate".
|
||||
|
||||
@Test
|
||||
fun `GIVEN DEX-only providers WHEN resolveBadge for the best DEX THEN BestTrade`() {
|
||||
val states = mapOf(
|
||||
dex1 to quote(fromFiat = "100", toFiat = "120"), // best DEX
|
||||
dexBridge to quote(fromFiat = "100", toFiat = "110"),
|
||||
)
|
||||
|
||||
val badge = SwapProviderResolver.resolveBadge(
|
||||
states = states,
|
||||
provider = dex1,
|
||||
needApplyFCARestrictions = false,
|
||||
state = states.getValue(dex1),
|
||||
isSwapBestDexRateEnabled = true,
|
||||
)
|
||||
|
||||
assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.BestTrade)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN DEX-only providers WHEN resolveBadge for a non-best DEX THEN Empty`() {
|
||||
val states = mapOf(
|
||||
dex1 to quote(fromFiat = "100", toFiat = "120"), // best DEX
|
||||
dexBridge to quote(fromFiat = "100", toFiat = "110"),
|
||||
)
|
||||
|
||||
val badge = SwapProviderResolver.resolveBadge(
|
||||
states = states,
|
||||
provider = dexBridge,
|
||||
needApplyFCARestrictions = false,
|
||||
state = states.getValue(dexBridge),
|
||||
isSwapBestDexRateEnabled = true,
|
||||
)
|
||||
|
||||
assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.Empty)
|
||||
}
|
||||
|
||||
// --- CEX-only → only "Best rate" (BestTrade) on the best CEX (no DEX exists).
|
||||
|
||||
@Test
|
||||
fun `GIVEN CEX-only providers WHEN resolveBadge for the best CEX THEN BestTrade`() {
|
||||
val states = mapOf(
|
||||
cex1 to quote(fromFiat = "100", toFiat = "120"), // best CEX
|
||||
cex2 to quote(fromFiat = "100", toFiat = "110"),
|
||||
)
|
||||
|
||||
val badge = SwapProviderResolver.resolveBadge(
|
||||
states = states,
|
||||
provider = cex1,
|
||||
needApplyFCARestrictions = false,
|
||||
state = states.getValue(cex1),
|
||||
isSwapBestDexRateEnabled = true,
|
||||
)
|
||||
|
||||
assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.BestTrade)
|
||||
}
|
||||
|
||||
// --- Toggle off → only the overall best gets BestTrade; "Best DEX rate" is never produced.
|
||||
|
||||
@Test
|
||||
fun `GIVEN toggle off AND both types present with CEX best WHEN resolveBadge for the CEX THEN BestTrade`() {
|
||||
val states = mapOf(
|
||||
cex1 to quote(fromFiat = "100", toFiat = "120"), // best overall
|
||||
dex1 to quote(fromFiat = "100", toFiat = "110"),
|
||||
)
|
||||
|
||||
val badge = SwapProviderResolver.resolveBadge(
|
||||
states = states,
|
||||
provider = cex1,
|
||||
needApplyFCARestrictions = false,
|
||||
state = states.getValue(cex1),
|
||||
isSwapBestDexRateEnabled = false,
|
||||
)
|
||||
|
||||
assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.BestTrade)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN toggle off AND both types present with CEX best WHEN resolveBadge for the DEX THEN Empty`() {
|
||||
val states = mapOf(
|
||||
cex1 to quote(fromFiat = "100", toFiat = "120"), // best overall
|
||||
dex1 to quote(fromFiat = "100", toFiat = "110"),
|
||||
)
|
||||
|
||||
val badge = SwapProviderResolver.resolveBadge(
|
||||
states = states,
|
||||
provider = dex1,
|
||||
needApplyFCARestrictions = false,
|
||||
state = states.getValue(dex1),
|
||||
isSwapBestDexRateEnabled = false,
|
||||
)
|
||||
|
||||
// Toggle off → no "Best DEX rate" badge even though a DEX is present and not the overall best.
|
||||
assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.Empty)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN FCA restricted provider AND restrictions on WHEN resolveBadge THEN FCAWarningList`() {
|
||||
val restricted = provider(id = "changelly", type = ExchangeProviderType.CEX, isRecommended = true)
|
||||
val states = mapOf(
|
||||
restricted to quote(fromFiat = "100", toFiat = "120"),
|
||||
cex2 to quote(fromFiat = "100", toFiat = "110"),
|
||||
)
|
||||
|
||||
val badge = SwapProviderResolver.resolveBadge(
|
||||
states = states,
|
||||
provider = restricted,
|
||||
needApplyFCARestrictions = true,
|
||||
state = states.getValue(restricted),
|
||||
isSwapBestDexRateEnabled = true,
|
||||
)
|
||||
|
||||
assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.FCAWarningList)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN restrictions on WHEN resolveBadge for each FCA restricted id THEN FCAWarningList`() {
|
||||
// FCA badge must win regardless of rate — the restricted provider here is NOT the best rate.
|
||||
fcaRestrictedProviderIds.forEach { restrictedId ->
|
||||
val restricted = provider(id = restrictedId, type = ExchangeProviderType.CEX)
|
||||
val states = mapOf(
|
||||
restricted to quote(fromFiat = "100", toFiat = "110"),
|
||||
cex2 to quote(fromFiat = "100", toFiat = "120"), // best, but not FCA restricted
|
||||
)
|
||||
|
||||
val badge = SwapProviderResolver.resolveBadge(
|
||||
states = states,
|
||||
provider = restricted,
|
||||
needApplyFCARestrictions = true,
|
||||
state = states.getValue(restricted),
|
||||
isSwapBestDexRateEnabled = true,
|
||||
)
|
||||
|
||||
assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.FCAWarningList)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN FCA restricted provider but restrictions off WHEN resolveBadge THEN no FCA badge`() {
|
||||
val restricted = provider(id = "changelly", type = ExchangeProviderType.CEX)
|
||||
val states = mapOf(
|
||||
restricted to quote(fromFiat = "100", toFiat = "120"), // best rated
|
||||
cex2 to quote(fromFiat = "100", toFiat = "110"),
|
||||
)
|
||||
|
||||
val badge = SwapProviderResolver.resolveBadge(
|
||||
states = states,
|
||||
provider = restricted,
|
||||
needApplyFCARestrictions = false,
|
||||
state = states.getValue(restricted),
|
||||
isSwapBestDexRateEnabled = false,
|
||||
)
|
||||
|
||||
// Restrictions are off → the restricted id is ignored and the normal best-rate badge wins.
|
||||
assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.BestTrade)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN restrictions on for a non-restricted best provider WHEN resolveBadge THEN best rate suppressed`() {
|
||||
val states = mapOf(
|
||||
cex1 to quote(fromFiat = "100", toFiat = "120"), // best & not restricted
|
||||
cex2 to quote(fromFiat = "100", toFiat = "110"),
|
||||
)
|
||||
|
||||
val badge = SwapProviderResolver.resolveBadge(
|
||||
states = states,
|
||||
provider = cex1,
|
||||
needApplyFCARestrictions = true,
|
||||
state = states.getValue(cex1),
|
||||
isSwapBestDexRateEnabled = true,
|
||||
)
|
||||
|
||||
// FCA restrictions globally on suppress the best-rate badge even for non-restricted providers.
|
||||
assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.Empty)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN permission required WHEN resolveBadge THEN PermissionRequired`() {
|
||||
val states = mapOf(
|
||||
cex1 to quote(
|
||||
fromFiat = "100",
|
||||
toFiat = "120",
|
||||
permission = PermissionDataState.PermissionRequired(isResetApproval = false, spenderAddress = "0x"),
|
||||
),
|
||||
cex2 to quote(fromFiat = "100", toFiat = "110"),
|
||||
)
|
||||
|
||||
val badge = SwapProviderResolver.resolveBadge(
|
||||
states = states,
|
||||
provider = cex1,
|
||||
needApplyFCARestrictions = false,
|
||||
state = states.getValue(cex1),
|
||||
isSwapBestDexRateEnabled = true,
|
||||
)
|
||||
|
||||
assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.PermissionRequired)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN recommended provider WHEN resolveBadge THEN Recommended takes priority over best rate`() {
|
||||
val recommended = provider(id = "cex1", type = ExchangeProviderType.CEX, isRecommended = true)
|
||||
val states = mapOf(
|
||||
recommended to quote(fromFiat = "100", toFiat = "120"), // also the best rate
|
||||
cex2 to quote(fromFiat = "100", toFiat = "110"),
|
||||
)
|
||||
|
||||
val badge = SwapProviderResolver.resolveBadge(
|
||||
states = states,
|
||||
provider = recommended,
|
||||
needApplyFCARestrictions = false,
|
||||
state = states.getValue(recommended),
|
||||
isSwapBestDexRateEnabled = true,
|
||||
)
|
||||
|
||||
assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.Recommended)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN only one considered provider WHEN resolveBadge THEN Empty`() {
|
||||
val states = mapOf(cex1 to quote(fromFiat = "100", toFiat = "120"))
|
||||
|
||||
val badge = SwapProviderResolver.resolveBadge(
|
||||
states = states,
|
||||
provider = cex1,
|
||||
needApplyFCARestrictions = false,
|
||||
state = states.getValue(cex1),
|
||||
isSwapBestDexRateEnabled = true,
|
||||
)
|
||||
|
||||
assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.Empty)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN provider is not the best rated WHEN resolveBadge THEN Empty`() {
|
||||
val states = mapOf(
|
||||
cex1 to quote(fromFiat = "100", toFiat = "120"), // best
|
||||
cex2 to quote(fromFiat = "100", toFiat = "110"),
|
||||
)
|
||||
|
||||
val badge = SwapProviderResolver.resolveBadge(
|
||||
states = states,
|
||||
provider = cex2, // not the best
|
||||
needApplyFCARestrictions = false,
|
||||
state = states.getValue(cex2),
|
||||
isSwapBestDexRateEnabled = true,
|
||||
)
|
||||
|
||||
assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.Empty)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN best rated provider but price impact warning WHEN resolveBadge THEN Empty`() {
|
||||
val states = mapOf(
|
||||
cex1 to quote(fromFiat = "100", toFiat = "120", priceImpactWarning = true), // best, but warning
|
||||
cex2 to quote(fromFiat = "100", toFiat = "110"),
|
||||
)
|
||||
|
||||
val badge = SwapProviderResolver.resolveBadge(
|
||||
states = states,
|
||||
provider = cex1,
|
||||
needApplyFCARestrictions = false,
|
||||
state = states.getValue(cex1),
|
||||
isSwapBestDexRateEnabled = true,
|
||||
)
|
||||
|
||||
assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.Empty)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN no quote state (error row) WHEN resolveBadge THEN Empty`() {
|
||||
val states = mapOf(
|
||||
cex1 to quote(fromFiat = "100", toFiat = "120"),
|
||||
cex2 to quote(fromFiat = "100", toFiat = "110"),
|
||||
)
|
||||
|
||||
val badge = SwapProviderResolver.resolveBadge(
|
||||
states = states,
|
||||
provider = cex1,
|
||||
needApplyFCARestrictions = false,
|
||||
state = null,
|
||||
isSwapBestDexRateEnabled = true,
|
||||
)
|
||||
|
||||
assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.Empty)
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
private fun provider(
|
||||
id: String,
|
||||
type: ExchangeProviderType,
|
||||
isRecommended: Boolean = false,
|
||||
): SwapProvider = mockk {
|
||||
every { providerId } returns id
|
||||
every { this@mockk.type } returns type
|
||||
every { this@mockk.isRecommended } returns isRecommended
|
||||
}
|
||||
|
||||
private fun quote(
|
||||
fromFiat: String,
|
||||
toFiat: String,
|
||||
priceImpactWarning: Boolean = false,
|
||||
permission: PermissionDataState = PermissionDataState.Empty,
|
||||
): SwapState.QuotesLoadedState {
|
||||
val currency = mockk<CryptoCurrency.Coin> {
|
||||
every { decimals } returns 6
|
||||
}
|
||||
val swapStatus = mockk<SwapCurrencyStatus> {
|
||||
every { this@mockk.currency } returns currency
|
||||
}
|
||||
val fromInfo = mockk<TokenSwapInfo> {
|
||||
every { amountFiat } returns BigDecimal(fromFiat)
|
||||
}
|
||||
val toInfo = mockk<TokenSwapInfo> {
|
||||
every { amountFiat } returns BigDecimal(toFiat)
|
||||
every { swapCurrencyStatus } returns swapStatus
|
||||
}
|
||||
return mockk {
|
||||
every { fromTokenInfo } returns fromInfo
|
||||
every { toTokenInfo } returns toInfo
|
||||
every { permissionState } returns permission
|
||||
every { priceImpact } returns mockk<PriceImpact> { every { shouldShowWarning() } returns priceImpactWarning }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import com.tangem.feature.swap.domain.models.SwapAmount
|
|||
import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType
|
||||
import com.tangem.feature.swap.domain.models.domain.SwapProvider
|
||||
import com.tangem.feature.swap.domain.models.ui.PermissionDataState
|
||||
import com.tangem.feature.swap.domain.models.ui.SwapState
|
||||
import com.tangem.feature.swap.domain.models.ui.TokenSwapInfo
|
||||
import com.tangem.domain.swap.models.SwapCurrencyStatus
|
||||
import com.tangem.feature.swap.models.states.PercentDifference
|
||||
|
|
@ -20,6 +21,14 @@ import org.junit.jupiter.api.Test
|
|||
import java.math.BigDecimal
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Tests for [SwapProviderStateBuilder].
|
||||
*
|
||||
* Badge selection lives in [SwapProviderResolver]; here the badge is supplied as [additionalBadge]
|
||||
* and the builder is expected to render it verbatim. These tests therefore focus on the builder's
|
||||
* own responsibilities — subtitle formatting, percent-delta mapping, provider identity, and passing
|
||||
* the badge through — not on badge-decision logic.
|
||||
*/
|
||||
internal class SwapProviderStateBuilderTest {
|
||||
|
||||
private var originalLocale: Locale = Locale.getDefault()
|
||||
|
|
@ -40,24 +49,20 @@ internal class SwapProviderStateBuilderTest {
|
|||
// region buildContentClickable
|
||||
|
||||
@Test
|
||||
fun `GIVEN best rate AND no FCA AND no permission WHEN buildContentClickable THEN BestTrade badge`() {
|
||||
val provider = provider(id = "1inch", isRecommended = false)
|
||||
fun `GIVEN a badge WHEN buildContentClickable THEN it is rendered with a rate subtitle`() {
|
||||
val provider = provider(id = "1inch")
|
||||
val from = tokenInfo(symbol = "ETH", decimals = 18, amount = BigDecimal.ONE)
|
||||
val to = tokenInfo(symbol = "USDT", decimals = 6, amount = BigDecimal("3000"))
|
||||
|
||||
val result = SwapProviderStateBuilder.buildContentClickable(
|
||||
provider = provider,
|
||||
fromTokenInfo = from,
|
||||
toTokenInfo = to,
|
||||
permissionState = PermissionDataState.Empty,
|
||||
state = quoteState(toTokenInfo = to, fromTokenInfo = from),
|
||||
selectionType = ProviderState.SelectionType.CLICK,
|
||||
isBestRate = true,
|
||||
isNeedBestRateBadge = true,
|
||||
needApplyFCARestrictions = false,
|
||||
additionalBadge = ProviderState.AdditionalBadge.BestDexRate,
|
||||
onProviderClick = onProviderClick,
|
||||
)
|
||||
|
||||
assertThat(result.additionalBadge).isEqualTo(ProviderState.AdditionalBadge.BestTrade)
|
||||
assertThat(result.additionalBadge).isEqualTo(ProviderState.AdditionalBadge.BestDexRate)
|
||||
assertThat(result.percentLowerThenBest).isEqualTo(PercentDifference.Empty)
|
||||
assertThat(result.subtitle).isInstanceOf(TextReference.Str::class.java)
|
||||
val subtitle = result.subtitle as TextReference.Str
|
||||
|
|
@ -65,106 +70,16 @@ internal class SwapProviderStateBuilderTest {
|
|||
assertThat(subtitle.value).contains("USDT")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN recommended provider WHEN buildContentClickable THEN Recommended badge`() {
|
||||
val provider = provider(id = "any", isRecommended = true)
|
||||
val info = tokenInfo(symbol = "ETH", decimals = 18, amount = BigDecimal.ONE)
|
||||
|
||||
val result = SwapProviderStateBuilder.buildContentClickable(
|
||||
provider = provider,
|
||||
fromTokenInfo = info,
|
||||
toTokenInfo = info,
|
||||
permissionState = PermissionDataState.Empty,
|
||||
selectionType = ProviderState.SelectionType.CLICK,
|
||||
isBestRate = true,
|
||||
isNeedBestRateBadge = true,
|
||||
needApplyFCARestrictions = false,
|
||||
onProviderClick = onProviderClick,
|
||||
)
|
||||
|
||||
assertThat(result.additionalBadge).isEqualTo(ProviderState.AdditionalBadge.Recommended)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN permission required WHEN buildContentClickable THEN PermissionRequired badge`() {
|
||||
val provider = provider(id = "any", isRecommended = false)
|
||||
val info = tokenInfo(symbol = "ETH", decimals = 18, amount = BigDecimal.ONE)
|
||||
|
||||
val result = SwapProviderStateBuilder.buildContentClickable(
|
||||
provider = provider,
|
||||
fromTokenInfo = info,
|
||||
toTokenInfo = info,
|
||||
permissionState = PermissionDataState.PermissionRequired(
|
||||
isResetApproval = false,
|
||||
spenderAddress = "0xspender",
|
||||
),
|
||||
selectionType = ProviderState.SelectionType.CLICK,
|
||||
isBestRate = true,
|
||||
isNeedBestRateBadge = true,
|
||||
needApplyFCARestrictions = false,
|
||||
onProviderClick = onProviderClick,
|
||||
)
|
||||
|
||||
assertThat(result.additionalBadge).isEqualTo(ProviderState.AdditionalBadge.PermissionRequired)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN FCA restricted provider WHEN buildContentClickable THEN FCAWarningList badge`() {
|
||||
val provider = provider(id = "changelly", isRecommended = true)
|
||||
val info = tokenInfo(symbol = "ETH", decimals = 18, amount = BigDecimal.ONE)
|
||||
|
||||
val result = SwapProviderStateBuilder.buildContentClickable(
|
||||
provider = provider,
|
||||
fromTokenInfo = info,
|
||||
toTokenInfo = info,
|
||||
permissionState = PermissionDataState.PermissionRequired(
|
||||
isResetApproval = false,
|
||||
spenderAddress = "0xspender",
|
||||
),
|
||||
selectionType = ProviderState.SelectionType.CLICK,
|
||||
isBestRate = true,
|
||||
isNeedBestRateBadge = true,
|
||||
needApplyFCARestrictions = true,
|
||||
onProviderClick = onProviderClick,
|
||||
)
|
||||
|
||||
assertThat(result.additionalBadge).isEqualTo(ProviderState.AdditionalBadge.FCAWarningList)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN best rate badge disabled WHEN buildContentClickable THEN Empty badge`() {
|
||||
val provider = provider(id = "any", isRecommended = false)
|
||||
val info = tokenInfo(symbol = "ETH", decimals = 18, amount = BigDecimal.ONE)
|
||||
|
||||
val result = SwapProviderStateBuilder.buildContentClickable(
|
||||
provider = provider,
|
||||
fromTokenInfo = info,
|
||||
toTokenInfo = info,
|
||||
permissionState = PermissionDataState.Empty,
|
||||
selectionType = ProviderState.SelectionType.CLICK,
|
||||
isBestRate = true,
|
||||
isNeedBestRateBadge = false,
|
||||
needApplyFCARestrictions = false,
|
||||
onProviderClick = onProviderClick,
|
||||
)
|
||||
|
||||
assertThat(result.additionalBadge).isEqualTo(ProviderState.AdditionalBadge.Empty)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN provider WHEN buildContentClickable THEN content carries provider identity`() {
|
||||
val provider = provider(id = "1inch", isRecommended = false, name = "1inch", iconUrl = "https://x")
|
||||
val provider = provider(id = "1inch", name = "1inch", iconUrl = "https://x")
|
||||
val info = tokenInfo(symbol = "ETH", decimals = 18, amount = BigDecimal.ONE)
|
||||
|
||||
val result = SwapProviderStateBuilder.buildContentClickable(
|
||||
provider = provider,
|
||||
fromTokenInfo = info,
|
||||
toTokenInfo = info,
|
||||
permissionState = PermissionDataState.Empty,
|
||||
state = quoteState(toTokenInfo = info),
|
||||
selectionType = ProviderState.SelectionType.CLICK,
|
||||
isBestRate = false,
|
||||
isNeedBestRateBadge = false,
|
||||
needApplyFCARestrictions = false,
|
||||
additionalBadge = ProviderState.AdditionalBadge.Empty,
|
||||
onProviderClick = onProviderClick,
|
||||
)
|
||||
|
||||
|
|
@ -182,16 +97,15 @@ internal class SwapProviderStateBuilderTest {
|
|||
|
||||
@Test
|
||||
fun `GIVEN provider in pricesLowerBest WHEN buildContentSelectable THEN percentLowerThenBest is mapped`() {
|
||||
val provider = provider(id = "1inch", isRecommended = false)
|
||||
val provider = provider(id = "1inch")
|
||||
val info = tokenInfo(symbol = "USDT", decimals = 6, amount = BigDecimal("100"))
|
||||
|
||||
val result = SwapProviderStateBuilder.buildContentSelectable(
|
||||
provider = provider,
|
||||
toTokenInfo = info,
|
||||
permissionState = PermissionDataState.Empty,
|
||||
state = quoteState(toTokenInfo = info),
|
||||
pricesLowerBest = mapOf("1inch" to 0.5f),
|
||||
selectionType = ProviderState.SelectionType.SELECT,
|
||||
needApplyFCARestrictions = false,
|
||||
additionalBadge = ProviderState.AdditionalBadge.Empty,
|
||||
onProviderClick = onProviderClick,
|
||||
)
|
||||
|
||||
|
|
@ -203,16 +117,15 @@ internal class SwapProviderStateBuilderTest {
|
|||
|
||||
@Test
|
||||
fun `GIVEN provider not in pricesLowerBest WHEN buildContentSelectable THEN percentLowerThenBest is zero`() {
|
||||
val provider = provider(id = "any", isRecommended = false)
|
||||
val provider = provider(id = "any")
|
||||
val info = tokenInfo(symbol = "USDT", decimals = 6, amount = BigDecimal("100"))
|
||||
|
||||
val result = SwapProviderStateBuilder.buildContentSelectable(
|
||||
provider = provider,
|
||||
toTokenInfo = info,
|
||||
permissionState = PermissionDataState.Empty,
|
||||
state = quoteState(toTokenInfo = info),
|
||||
pricesLowerBest = emptyMap(),
|
||||
selectionType = ProviderState.SelectionType.SELECT,
|
||||
needApplyFCARestrictions = false,
|
||||
additionalBadge = ProviderState.AdditionalBadge.Empty,
|
||||
onProviderClick = onProviderClick,
|
||||
)
|
||||
|
||||
|
|
@ -220,84 +133,22 @@ internal class SwapProviderStateBuilderTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN best rate AND no FCA AND no permission WHEN buildContentSelectable THEN BestTrade badge`() {
|
||||
val provider = provider(id = "any", isRecommended = false)
|
||||
fun `GIVEN a badge WHEN buildContentSelectable THEN it is rendered`() {
|
||||
val provider = provider(id = "any")
|
||||
val info = tokenInfo(symbol = "USDT", decimals = 6, amount = BigDecimal("100"))
|
||||
|
||||
val result = SwapProviderStateBuilder.buildContentSelectable(
|
||||
provider = provider,
|
||||
toTokenInfo = info,
|
||||
permissionState = PermissionDataState.Empty,
|
||||
state = quoteState(toTokenInfo = info),
|
||||
pricesLowerBest = emptyMap(),
|
||||
selectionType = ProviderState.SelectionType.SELECT,
|
||||
needApplyFCARestrictions = false,
|
||||
isBestRate = true,
|
||||
isNeedBestRateBadge = true,
|
||||
additionalBadge = ProviderState.AdditionalBadge.BestTrade,
|
||||
onProviderClick = onProviderClick,
|
||||
)
|
||||
|
||||
assertThat(result.additionalBadge).isEqualTo(ProviderState.AdditionalBadge.BestTrade)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN isNeedBestRateBadge false WHEN buildContentSelectable THEN no BestTrade badge`() {
|
||||
val provider = provider(id = "any", isRecommended = false)
|
||||
val info = tokenInfo(symbol = "USDT", decimals = 6, amount = BigDecimal("100"))
|
||||
|
||||
val result = SwapProviderStateBuilder.buildContentSelectable(
|
||||
provider = provider,
|
||||
toTokenInfo = info,
|
||||
permissionState = PermissionDataState.Empty,
|
||||
pricesLowerBest = emptyMap(),
|
||||
selectionType = ProviderState.SelectionType.SELECT,
|
||||
needApplyFCARestrictions = false,
|
||||
isBestRate = true,
|
||||
isNeedBestRateBadge = false,
|
||||
onProviderClick = onProviderClick,
|
||||
)
|
||||
|
||||
assertThat(result.additionalBadge).isEqualTo(ProviderState.AdditionalBadge.Empty)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN isBestRate false AND badge enabled WHEN buildContentSelectable THEN no BestTrade badge`() {
|
||||
val provider = provider(id = "any", isRecommended = false)
|
||||
val info = tokenInfo(symbol = "USDT", decimals = 6, amount = BigDecimal("100"))
|
||||
|
||||
val result = SwapProviderStateBuilder.buildContentSelectable(
|
||||
provider = provider,
|
||||
toTokenInfo = info,
|
||||
permissionState = PermissionDataState.Empty,
|
||||
pricesLowerBest = emptyMap(),
|
||||
selectionType = ProviderState.SelectionType.SELECT,
|
||||
needApplyFCARestrictions = false,
|
||||
isBestRate = false,
|
||||
isNeedBestRateBadge = true,
|
||||
onProviderClick = onProviderClick,
|
||||
)
|
||||
|
||||
assertThat(result.additionalBadge).isEqualTo(ProviderState.AdditionalBadge.Empty)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN permission required WHEN buildContentSelectable THEN PermissionRequired badge`() {
|
||||
val provider = provider(id = "any", isRecommended = false)
|
||||
val info = tokenInfo(symbol = "USDT", decimals = 6, amount = BigDecimal("100"))
|
||||
|
||||
val result = SwapProviderStateBuilder.buildContentSelectable(
|
||||
provider = provider,
|
||||
toTokenInfo = info,
|
||||
permissionState = PermissionDataState.PermissionRequired(
|
||||
isResetApproval = false,
|
||||
spenderAddress = "0xspender",
|
||||
),
|
||||
pricesLowerBest = emptyMap(),
|
||||
selectionType = ProviderState.SelectionType.SELECT,
|
||||
needApplyFCARestrictions = false,
|
||||
onProviderClick = onProviderClick,
|
||||
)
|
||||
|
||||
assertThat(result.additionalBadge).isEqualTo(ProviderState.AdditionalBadge.PermissionRequired)
|
||||
assertThat(result.subtitle).isInstanceOf(TextReference.Str::class.java)
|
||||
assertThat((result.subtitle as TextReference.Str).value).contains("USDT")
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
|
@ -305,67 +156,23 @@ internal class SwapProviderStateBuilderTest {
|
|||
// region buildAvailableFrom
|
||||
|
||||
@Test
|
||||
fun `GIVEN alert text WHEN buildAvailableFrom THEN subtitle is the alert text`() {
|
||||
val provider = provider(id = "any", isRecommended = false)
|
||||
fun `GIVEN alert text WHEN buildAvailableFrom THEN subtitle is the alert text and badge is rendered`() {
|
||||
val provider = provider(id = "any")
|
||||
val alert: TextReference = stringReference("min amount 0.01 ETH")
|
||||
|
||||
val result = SwapProviderStateBuilder.buildAvailableFrom(
|
||||
provider = provider,
|
||||
alertText = alert,
|
||||
selectionType = ProviderState.SelectionType.SELECT,
|
||||
needApplyFCARestrictions = false,
|
||||
additionalBadge = ProviderState.AdditionalBadge.FCAWarningList,
|
||||
onProviderClick = onProviderClick,
|
||||
)
|
||||
|
||||
assertThat(result.subtitle).isEqualTo(alert)
|
||||
assertThat(result.percentLowerThenBest).isEqualTo(PercentDifference.Empty)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN FCA restricted WHEN buildAvailableFrom THEN FCAWarningList badge`() {
|
||||
val provider = provider(id = "okx-on-chain", isRecommended = true)
|
||||
|
||||
val result = SwapProviderStateBuilder.buildAvailableFrom(
|
||||
provider = provider,
|
||||
alertText = TextReference.EMPTY,
|
||||
selectionType = ProviderState.SelectionType.SELECT,
|
||||
needApplyFCARestrictions = true,
|
||||
onProviderClick = onProviderClick,
|
||||
)
|
||||
|
||||
assertThat(result.additionalBadge).isEqualTo(ProviderState.AdditionalBadge.FCAWarningList)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN recommended WHEN buildAvailableFrom THEN Recommended badge`() {
|
||||
val provider = provider(id = "any", isRecommended = true)
|
||||
|
||||
val result = SwapProviderStateBuilder.buildAvailableFrom(
|
||||
provider = provider,
|
||||
alertText = TextReference.EMPTY,
|
||||
selectionType = ProviderState.SelectionType.SELECT,
|
||||
needApplyFCARestrictions = false,
|
||||
onProviderClick = onProviderClick,
|
||||
)
|
||||
|
||||
assertThat(result.additionalBadge).isEqualTo(ProviderState.AdditionalBadge.Recommended)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN no flags WHEN buildAvailableFrom THEN Empty badge`() {
|
||||
val provider = provider(id = "any", isRecommended = false)
|
||||
|
||||
val result = SwapProviderStateBuilder.buildAvailableFrom(
|
||||
provider = provider,
|
||||
alertText = TextReference.EMPTY,
|
||||
selectionType = ProviderState.SelectionType.SELECT,
|
||||
needApplyFCARestrictions = false,
|
||||
onProviderClick = onProviderClick,
|
||||
)
|
||||
|
||||
assertThat(result.additionalBadge).isEqualTo(ProviderState.AdditionalBadge.Empty)
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region buildSelectableSubtitle
|
||||
|
|
@ -386,7 +193,6 @@ internal class SwapProviderStateBuilderTest {
|
|||
|
||||
private fun provider(
|
||||
id: String,
|
||||
isRecommended: Boolean,
|
||||
name: String = "Provider",
|
||||
iconUrl: String = "https://icon",
|
||||
): SwapProvider = mockk {
|
||||
|
|
@ -394,7 +200,16 @@ internal class SwapProviderStateBuilderTest {
|
|||
every { this@mockk.name } returns name
|
||||
every { imageLarge } returns iconUrl
|
||||
every { type } returns ExchangeProviderType.DEX
|
||||
every { this@mockk.isRecommended } returns isRecommended
|
||||
}
|
||||
|
||||
private fun quoteState(
|
||||
toTokenInfo: TokenSwapInfo,
|
||||
fromTokenInfo: TokenSwapInfo = toTokenInfo,
|
||||
permissionState: PermissionDataState = PermissionDataState.Empty,
|
||||
): SwapState.QuotesLoadedState = mockk {
|
||||
every { this@mockk.fromTokenInfo } returns fromTokenInfo
|
||||
every { this@mockk.toTokenInfo } returns toTokenInfo
|
||||
every { this@mockk.permissionState } returns permissionState
|
||||
}
|
||||
|
||||
private fun tokenInfo(symbol: String, decimals: Int, amount: BigDecimal): TokenSwapInfo {
|
||||
|
|
|
|||
|
|
@ -125,7 +125,7 @@ internal abstract class SwapModelTestBase {
|
|||
|
||||
protected fun createParams(): SwapComponent.Params = SwapComponent.Params(
|
||||
userWalletId = userWalletId,
|
||||
cryptoCurrency = null,
|
||||
fromCryptoCurrency = null,
|
||||
screenSource = "Test",
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -120,12 +120,14 @@ internal class PreviewEmptyExpressTransactionsComponent : ExpressTransactionsCom
|
|||
toAmountSymbol = toSymbol,
|
||||
toCurrencyIcon = CurrencyIconState.Empty(),
|
||||
toAddress = "",
|
||||
toAmountDecimals = 2,
|
||||
fromAmount = TextReference.Str(fromAmount),
|
||||
fromAmountValue = fromAmount.toBigDecimal(),
|
||||
fromFiatAmount = null,
|
||||
fromAmountSymbol = fromSymbol,
|
||||
fromCurrencyIcon = CurrencyIconState.Empty(),
|
||||
fromAddress = "",
|
||||
fromAmountDecimals = 2,
|
||||
),
|
||||
providerName = "Preview Provider",
|
||||
providerImageUrl = "",
|
||||
|
|
|
|||
|
|
@ -473,9 +473,9 @@ internal class TangemPayCardPageModel @Inject constructor(
|
|||
bottomSheetNavigation.dismiss()
|
||||
router.push(
|
||||
AppRoute.Swap(
|
||||
cryptoCurrency = data.currency,
|
||||
fromCryptoCurrency = data.currency,
|
||||
userWalletId = data.walletId,
|
||||
currencyPosition = AppRoute.Swap.CurrencyPosition.TO,
|
||||
fromCurrencyPosition = AppRoute.Swap.CurrencyPosition.TO,
|
||||
screenSource = AnalyticsParam.ScreensSources.TangemPay.value,
|
||||
tangemPayInput = AppRoute.Swap.TangemPayInput(
|
||||
cryptoAmount = data.cryptoBalance,
|
||||
|
|
|
|||
|
|
@ -216,10 +216,10 @@ internal class TangemPayDetailsModel @Inject constructor(
|
|||
}
|
||||
router.push(
|
||||
AppRoute.Swap(
|
||||
cryptoCurrency = currency,
|
||||
fromCryptoCurrency = currency,
|
||||
userWalletId = userWalletId,
|
||||
screenSource = AnalyticsParam.ScreensSources.TangemPay.value,
|
||||
currencyPosition = AppRoute.Swap.CurrencyPosition.FROM,
|
||||
fromCurrencyPosition = AppRoute.Swap.CurrencyPosition.FROM,
|
||||
tangemPayInput = AppRoute.Swap.TangemPayInput(
|
||||
cryptoAmount = balance.availableForWithdrawal,
|
||||
fiatAmount = balance.availableForWithdrawal,
|
||||
|
|
@ -308,10 +308,10 @@ internal class TangemPayDetailsModel @Inject constructor(
|
|||
bottomSheetNavigation.dismiss()
|
||||
router.push(
|
||||
AppRoute.Swap(
|
||||
cryptoCurrency = data.currency,
|
||||
fromCryptoCurrency = data.currency,
|
||||
userWalletId = data.walletId,
|
||||
screenSource = AnalyticsParam.ScreensSources.TangemPay.value,
|
||||
currencyPosition = AppRoute.Swap.CurrencyPosition.TO,
|
||||
fromCurrencyPosition = AppRoute.Swap.CurrencyPosition.TO,
|
||||
tangemPayInput = AppRoute.Swap.TangemPayInput(
|
||||
cryptoAmount = data.cryptoBalance,
|
||||
fiatAmount = data.fiatBalance,
|
||||
|
|
|
|||
|
|
@ -836,10 +836,10 @@ internal class TokenDetailsModel @Inject constructor(
|
|||
} else {
|
||||
appRouter.push(
|
||||
AppRoute.Swap(
|
||||
cryptoCurrency = cryptoCurrency,
|
||||
fromCryptoCurrency = cryptoCurrency,
|
||||
userWalletId = userWalletId,
|
||||
screenSource = AnalyticsParam.ScreensSources.Token.value,
|
||||
currencyPosition = currencyPosition,
|
||||
fromCurrencyPosition = currencyPosition,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -1322,7 +1322,7 @@ internal class TokenDetailsModel @Inject constructor(
|
|||
TokenAction.Send -> sendCurrency()
|
||||
TokenAction.Swap -> appRouter.push(
|
||||
AppRoute.Swap(
|
||||
cryptoCurrency = cryptoCurrency,
|
||||
fromCryptoCurrency = cryptoCurrency,
|
||||
userWalletId = userWalletId,
|
||||
screenSource = AnalyticsParam.ScreensSources.Token.value,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -61,9 +61,7 @@ internal class TokenDetailsOnrampTransactionStateConverter(
|
|||
),
|
||||
timestampAgoFormatted = mapFormattedDate(value.timestamp),
|
||||
activeStatus = value.status.toActiveStatusText(cryptoCurrency.name),
|
||||
toAmount = stringReference(
|
||||
value.toAmount.format { crypto(cryptoCurrency) },
|
||||
),
|
||||
toAmount = stringReference(value.toAmount.format { crypto(cryptoCurrency) }),
|
||||
toAmountValue = value.toAmount,
|
||||
toFiatAmount = stringReference(
|
||||
statusValue?.fiatRate?.multiply(value.toAmount).format {
|
||||
|
|
@ -73,6 +71,7 @@ internal class TokenDetailsOnrampTransactionStateConverter(
|
|||
)
|
||||
},
|
||||
),
|
||||
toAmountDecimals = cryptoCurrency.decimals,
|
||||
toAmountSymbol = cryptoCurrency.symbol,
|
||||
toCurrencyIcon = iconStateConverter.convert(cryptoCurrency),
|
||||
toAddress = statusValue?.networkAddress?.defaultAddress?.value.orEmpty(),
|
||||
|
|
@ -91,6 +90,7 @@ internal class TokenDetailsOnrampTransactionStateConverter(
|
|||
url = value.fromCurrency.image,
|
||||
fallbackResId = R.drawable.ic_currency_24,
|
||||
),
|
||||
fromAmountDecimals = value.fromCurrency.precision,
|
||||
fromAddress = null,
|
||||
iconState = value.status.toIconState(),
|
||||
onGoToProviderClick = { url ->
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ internal class TokenDetailsSwapTransactionsStateConverter(
|
|||
|
||||
fun convert(
|
||||
savedTransactions: List<SavedSwapTransactionListModel>,
|
||||
accountStatuses: Map<Account, CryptoCurrencyStatus>,
|
||||
accountStatuses: Map<Account, List<CryptoCurrencyStatus>>,
|
||||
): PersistentList<ExchangeUM> {
|
||||
val result = mutableListOf<ExchangeUM>()
|
||||
|
||||
|
|
@ -67,29 +67,18 @@ internal class TokenDetailsSwapTransactionsStateConverter(
|
|||
.forEach { swapTransaction ->
|
||||
val toCryptoCurrency = swapTransaction.toCryptoCurrency
|
||||
val fromCryptoCurrency = swapTransaction.fromCryptoCurrency
|
||||
val toCryptoCurrencyRawId = swapTransaction.toCryptoCurrency.id.rawCurrencyId
|
||||
val fromCryptoCurrencyRawId = swapTransaction.fromCryptoCurrency.id.rawCurrencyId
|
||||
var fromCryptoCurrencyStatus: CryptoCurrencyStatus? = null
|
||||
var toCryptoCurrencyStatus: CryptoCurrencyStatus? = null
|
||||
|
||||
val (fromCryptoCurrencyStatus, toCryptoCurrencyStatus) = extractCryptoCurrencyStatuses(
|
||||
swapTransaction = swapTransaction,
|
||||
accountStatuses = accountStatuses,
|
||||
)
|
||||
|
||||
swapTransaction.transactions.forEach { transaction ->
|
||||
val toAmount = transaction.toCryptoAmount
|
||||
val fromAmount = transaction.fromCryptoAmount
|
||||
var toFiatAmount: BigDecimal? = null
|
||||
var fromFiatAmount: BigDecimal? = null
|
||||
accountStatuses.forEach { (account, cryptoCurrencyStatus) ->
|
||||
if (cryptoCurrencyStatus.currency.id.rawCurrencyId == fromCryptoCurrencyRawId &&
|
||||
account.userWalletId.stringValue == swapTransaction.fromUserWalletId
|
||||
) {
|
||||
fromFiatAmount = cryptoCurrencyStatus.value.fiatRate?.multiply(fromAmount)
|
||||
fromCryptoCurrencyStatus = cryptoCurrencyStatus
|
||||
}
|
||||
if (cryptoCurrencyStatus.currency.id.rawCurrencyId == toCryptoCurrencyRawId &&
|
||||
account.userWalletId.stringValue == swapTransaction.toUserWalletId
|
||||
) {
|
||||
toFiatAmount = cryptoCurrencyStatus.value.fiatRate?.multiply(toAmount)
|
||||
toCryptoCurrencyStatus = cryptoCurrencyStatus
|
||||
}
|
||||
}
|
||||
val toFiatAmount = toCryptoCurrencyStatus?.value?.fiatRate?.multiply(toAmount)
|
||||
val fromFiatAmount = fromCryptoCurrencyStatus?.value?.fiatRate?.multiply(fromAmount)
|
||||
|
||||
val statusModel = transaction.status
|
||||
val notification = getNotification(
|
||||
status = statusModel?.status,
|
||||
|
|
@ -153,6 +142,34 @@ internal class TokenDetailsSwapTransactionsStateConverter(
|
|||
)
|
||||
}
|
||||
|
||||
private fun extractCryptoCurrencyStatuses(
|
||||
swapTransaction: SavedSwapTransactionListModel,
|
||||
accountStatuses: Map<Account, List<CryptoCurrencyStatus>>,
|
||||
): Pair<CryptoCurrencyStatus?, CryptoCurrencyStatus?> {
|
||||
val toCryptoCurrencyId = swapTransaction.toCryptoCurrency.id
|
||||
val fromCryptoCurrencyId = swapTransaction.fromCryptoCurrency.id
|
||||
|
||||
var fromCryptoCurrencyStatus: CryptoCurrencyStatus? = null
|
||||
var toCryptoCurrencyStatus: CryptoCurrencyStatus? = null
|
||||
|
||||
accountStatuses.forEach { (account, cryptoCurrencyStatuses) ->
|
||||
cryptoCurrencyStatuses.forEach { cryptoCurrencyStatus ->
|
||||
if (cryptoCurrencyStatus.currency.id == fromCryptoCurrencyId &&
|
||||
account.userWalletId.stringValue == swapTransaction.fromUserWalletId
|
||||
) {
|
||||
fromCryptoCurrencyStatus = cryptoCurrencyStatus
|
||||
}
|
||||
if (cryptoCurrencyStatus.currency.id == toCryptoCurrencyId &&
|
||||
account.userWalletId.stringValue == swapTransaction.toUserWalletId
|
||||
) {
|
||||
toCryptoCurrencyStatus = cryptoCurrencyStatus
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fromCryptoCurrencyStatus to toCryptoCurrencyStatus
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
private fun createStateInfo(
|
||||
transaction: SavedSwapTransactionModel,
|
||||
|
|
@ -182,12 +199,14 @@ internal class TokenDetailsSwapTransactionsStateConverter(
|
|||
toFiatAmount = getFiatAmount(toFiatAmount),
|
||||
toCurrencyIcon = iconStateConverter.convert(toCryptoCurrency),
|
||||
toAmountSymbol = toCryptoCurrency.symbol,
|
||||
toAmountDecimals = toCryptoCurrency.decimals,
|
||||
toAddress = toStatusValue?.networkAddress?.defaultAddress?.value.orEmpty(),
|
||||
fromAmount = getCryptoAmount(transaction.fromCryptoAmount, fromCryptoCurrency),
|
||||
fromAmountValue = transaction.fromCryptoAmount,
|
||||
fromFiatAmount = getFiatAmount(fromFiatAmount),
|
||||
fromCurrencyIcon = iconStateConverter.convert(fromCryptoCurrency),
|
||||
fromAmountSymbol = fromCryptoCurrency.symbol,
|
||||
fromAmountDecimals = fromCryptoCurrency.decimals,
|
||||
fromAddress = fromStatusValue?.networkAddress?.defaultAddress?.value.orEmpty(),
|
||||
onClick = { clickIntents.onExpressTransactionClick(transaction.txId) },
|
||||
onGoToProviderClick = { url ->
|
||||
|
|
|
|||
|
|
@ -67,13 +67,12 @@ internal class ExchangeStatusFactory @AssistedInject constructor(
|
|||
).conflate()
|
||||
.map { savedTransactions ->
|
||||
val accountStatuses = savedTransactions
|
||||
?.flatMap { swapTransaction ->
|
||||
setOf(
|
||||
?.flatMapTo(mutableSetOf()) { swapTransaction ->
|
||||
listOf(
|
||||
swapTransaction.fromAccount to swapTransaction.fromCryptoCurrency,
|
||||
swapTransaction.toAccount to swapTransaction.toCryptoCurrency,
|
||||
)
|
||||
}
|
||||
?.toMap()
|
||||
?.getStatuses()
|
||||
.orEmpty()
|
||||
|
||||
|
|
@ -199,7 +198,7 @@ internal class ExchangeStatusFactory @AssistedInject constructor(
|
|||
|
||||
private fun getExchangeStatusState(
|
||||
savedTransactions: List<SavedSwapTransactionListModel>?,
|
||||
accountStatuses: Map<Account, CryptoCurrencyStatus>,
|
||||
accountStatuses: Map<Account, List<CryptoCurrencyStatus>>,
|
||||
): PersistentList<ExchangeUM> {
|
||||
if (savedTransactions == null) {
|
||||
return persistentListOf()
|
||||
|
|
@ -231,7 +230,7 @@ internal class ExchangeStatusFactory @AssistedInject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private suspend fun Map<Account?, CryptoCurrency>.getStatuses(): Map<Account, CryptoCurrencyStatus> {
|
||||
private suspend fun Set<Pair<Account?, CryptoCurrency>>.getStatuses(): Map<Account, List<CryptoCurrencyStatus>> {
|
||||
return mapNotNull { (account, cryptoCurrency) ->
|
||||
when (account) {
|
||||
is Account.CryptoPortfolio -> {
|
||||
|
|
@ -248,7 +247,10 @@ internal class ExchangeStatusFactory @AssistedInject constructor(
|
|||
).getOrNull()
|
||||
else -> null
|
||||
}
|
||||
}.toMap()
|
||||
}.groupBy(
|
||||
keySelector = { (account, _) -> account },
|
||||
valueTransform = { (_, status) -> status },
|
||||
)
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
|
|
|
|||
|
|
@ -54,12 +54,14 @@ class ExpressStatusBottomSheetStateProvider : PreviewParameterProvider<ExpressSt
|
|||
toAmountSymbol = "BTC",
|
||||
toCurrencyIcon = CurrencyIconState.Empty(),
|
||||
toAddress = "0x",
|
||||
toAmountDecimals = 2,
|
||||
fromAmount = TextReference.Str("5000 USDT"),
|
||||
fromAmountValue = "5000".toBigDecimal(),
|
||||
fromFiatAmount = TextReference.Str("$5000"),
|
||||
fromAmountSymbol = "USDT",
|
||||
fromCurrencyIcon = CurrencyIconState.Empty(),
|
||||
fromAddress = "0x",
|
||||
fromAmountDecimals = 2,
|
||||
),
|
||||
provider = SwapProvider(
|
||||
providerId = "1",
|
||||
|
|
|
|||
|
|
@ -46,6 +46,8 @@ import com.tangem.core.ui.ds.image.TangemIcon
|
|||
import com.tangem.core.ui.ds.image.TangemIconUM
|
||||
import com.tangem.core.ui.extensions.resolveReference
|
||||
import com.tangem.core.ui.extensions.stringResourceSafe
|
||||
import com.tangem.core.ui.format.bigdecimal.crypto
|
||||
import com.tangem.core.ui.format.bigdecimal.format
|
||||
import com.tangem.core.ui.res.TangemColorPalette
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
||||
|
|
@ -53,7 +55,6 @@ import com.tangem.core.ui.res.TangemThemeRedesign
|
|||
import com.tangem.core.ui.utils.toPx
|
||||
import com.tangem.feature.tokendetails.presentation.tokendetails.state.express.ExchangeUM
|
||||
import com.tangem.features.tokendetails.impl.R
|
||||
import com.tangem.utils.extensions.stripZeroPlainString
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import java.math.BigDecimal
|
||||
|
|
@ -128,55 +129,7 @@ private fun ExpressShareImageContent(
|
|||
.matchParentSize()
|
||||
.background(TangemColorPalette.Black),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(
|
||||
start = 40.dp,
|
||||
end = 40.dp,
|
||||
top = 40.dp,
|
||||
bottom = 20.dp,
|
||||
),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.img_tangem_logo_90_24),
|
||||
contentDescription = null,
|
||||
tint = TangemColorPalette.White,
|
||||
)
|
||||
SpacerH(36.dp)
|
||||
|
||||
ExpressShareImageAmount(
|
||||
prefix = stringResourceSafe(R.string.common_send),
|
||||
amountValue = state.info.fromAmountValue,
|
||||
currencyIconState = state.info.fromCurrencyIcon,
|
||||
currencySymbol = state.info.fromAmountSymbol,
|
||||
)
|
||||
ExpressShareImageAddress(
|
||||
prefix = stringResourceSafe(R.string.common_from),
|
||||
address = state.info.fromAddress,
|
||||
)
|
||||
|
||||
SpacerH(24.dp)
|
||||
ExpressShareImageSeparator()
|
||||
SpacerH(24.dp)
|
||||
|
||||
ExpressShareImageAmount(
|
||||
prefix = stringResourceSafe(R.string.common_receive),
|
||||
amountValue = state.info.toAmountValue,
|
||||
currencyIconState = state.info.toCurrencyIcon,
|
||||
currencySymbol = state.info.toAmountSymbol,
|
||||
)
|
||||
ExpressShareImageAddress(
|
||||
prefix = stringResourceSafe(R.string.common_to),
|
||||
address = state.info.toAddress,
|
||||
)
|
||||
SpacerH(24.dp)
|
||||
|
||||
ExpressShareImageProvider(state)
|
||||
Text(
|
||||
text = stringResourceSafe(R.string.express_transaction_id, state.info.txExternalId.orEmpty()),
|
||||
style = TangemTheme.typography2.bodySemibold16,
|
||||
color = TangemTheme.colors3.text.staticDark.secondary,
|
||||
)
|
||||
}
|
||||
ExpressShareInnerContent(state = state)
|
||||
Image(
|
||||
painter = painterResource(R.drawable.img_share_express_background),
|
||||
contentDescription = null,
|
||||
|
|
@ -188,10 +141,71 @@ private fun ExpressShareImageContent(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ExpressShareInnerContent(state: ExpressTransactionStateUM) {
|
||||
Column(
|
||||
modifier = Modifier.padding(
|
||||
start = 40.dp,
|
||||
end = 40.dp,
|
||||
top = 40.dp,
|
||||
bottom = 20.dp,
|
||||
),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.img_tangem_logo_90_24),
|
||||
contentDescription = null,
|
||||
tint = TangemColorPalette.White,
|
||||
)
|
||||
SpacerH(36.dp)
|
||||
|
||||
ExpressShareImageAmount(
|
||||
prefix = stringResourceSafe(R.string.common_send),
|
||||
amount = state.info.fromAmountValue,
|
||||
decimals = state.info.fromAmountDecimals,
|
||||
currencyIconState = state.info.fromCurrencyIcon,
|
||||
currencySymbol = state.info.fromAmountSymbol,
|
||||
)
|
||||
ExpressShareImageAddress(
|
||||
prefix = stringResourceSafe(R.string.common_from),
|
||||
address = state.info.fromAddress,
|
||||
)
|
||||
|
||||
SpacerH(24.dp)
|
||||
ExpressShareImageSeparator()
|
||||
SpacerH(24.dp)
|
||||
|
||||
ExpressShareImageAmount(
|
||||
prefix = stringResourceSafe(R.string.common_receive),
|
||||
amount = state.info.toAmountValue,
|
||||
decimals = state.info.toAmountDecimals,
|
||||
currencyIconState = state.info.toCurrencyIcon,
|
||||
currencySymbol = state.info.toAmountSymbol,
|
||||
)
|
||||
ExpressShareImageAddress(
|
||||
prefix = stringResourceSafe(R.string.common_to),
|
||||
address = state.info.toAddress,
|
||||
)
|
||||
SpacerH(24.dp)
|
||||
|
||||
ExpressShareImageProvider(state)
|
||||
if (state.info.txExternalId != null) {
|
||||
Text(
|
||||
text = stringResourceSafe(
|
||||
R.string.express_transaction_id,
|
||||
state.info.txExternalId.orEmpty(),
|
||||
),
|
||||
style = TangemTheme.typography2.bodySemibold16,
|
||||
color = TangemTheme.colors3.text.staticDark.secondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ExpressShareImageAmount(
|
||||
prefix: String,
|
||||
amountValue: BigDecimal,
|
||||
amount: BigDecimal,
|
||||
decimals: Int,
|
||||
currencyIconState: CurrencyIconState,
|
||||
currencySymbol: String,
|
||||
) {
|
||||
|
|
@ -205,7 +219,12 @@ private fun ExpressShareImageAmount(
|
|||
style = TangemTheme.typography2.bodySemibold16,
|
||||
)
|
||||
Text(
|
||||
text = amountValue.stripZeroPlainString(),
|
||||
text = amount.format {
|
||||
crypto(
|
||||
symbol = "",
|
||||
decimals = decimals,
|
||||
)
|
||||
},
|
||||
color = TangemTheme.colors3.text.staticDark.primary,
|
||||
style = TangemTheme.typography2.bodySemibold16,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -666,7 +666,7 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor(
|
|||
private fun navigateToSwap(cryptoCurrencyStatus: CryptoCurrencyStatus, userWalletId: UserWalletId) {
|
||||
appRouter.push(
|
||||
AppRoute.Swap(
|
||||
cryptoCurrency = cryptoCurrencyStatus.currency,
|
||||
fromCryptoCurrency = cryptoCurrencyStatus.currency,
|
||||
userWalletId = userWalletId,
|
||||
screenSource = AnalyticsParam.ScreensSources.LongTap.value,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ internal class SingleWalletOnrampTransactionConverter(
|
|||
},
|
||||
),
|
||||
toAmountSymbol = currency.symbol,
|
||||
toAmountDecimals = currency.decimals,
|
||||
toCurrencyIcon = iconStateConverter.convert(currency),
|
||||
toAddress = status.networkAddress?.defaultAddress?.value.orEmpty(),
|
||||
fromAmount = stringReference(
|
||||
|
|
@ -86,6 +87,7 @@ internal class SingleWalletOnrampTransactionConverter(
|
|||
fromAmountValue = value.fromAmount,
|
||||
fromFiatAmount = null,
|
||||
fromAmountSymbol = value.fromCurrency.code,
|
||||
fromAmountDecimals = value.fromCurrency.precision,
|
||||
fromCurrencyIcon = CurrencyIconState.FiatIcon(
|
||||
url = value.fromCurrency.image,
|
||||
fallbackResId = R.drawable.ic_currency_24,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue