diff --git a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt index 092b40ff02..d146fe91d5 100644 --- a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt +++ b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt @@ -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 diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt index d31ec820f0..5f2986c8dc 100644 --- a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt @@ -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 diff --git a/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/TokenActionsHandler.kt b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/TokenActionsHandler.kt index 0612566c9a..962a2334fe 100644 --- a/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/TokenActionsHandler.kt +++ b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/TokenActionsHandler.kt @@ -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, ), diff --git a/common/ui/src/main/java/com/tangem/common/ui/expressStatus/ExpressStatusItems.kt b/common/ui/src/main/java/com/tangem/common/ui/expressStatus/ExpressStatusItems.kt index c52bf8b905..5419047911 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/expressStatus/ExpressStatusItems.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/expressStatus/ExpressStatusItems.kt @@ -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 \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/expressStatus/state/ExpressTransactionStateUM.kt b/common/ui/src/main/java/com/tangem/common/ui/expressStatus/state/ExpressTransactionStateUM.kt index f2c1d7ff28..4131c8fa30 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/expressStatus/state/ExpressTransactionStateUM.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/expressStatus/state/ExpressTransactionStateUM.kt @@ -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, diff --git a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json index e3360199cf..595595a42b 100644 --- a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json +++ b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json @@ -150,5 +150,9 @@ { "name": "AND_15235_VISA_MULTIPLE_CARDS", "version": "5.40" + }, + { + "name": "AND_15715_SWAP_BEST_DEX_RATE_ENABLED", + "version": "undefined" } ] diff --git a/domain/swap/build.gradle.kts b/domain/swap/build.gradle.kts index 64c05120b6..14cc74adae 100644 --- a/domain/swap/build.gradle.kts +++ b/domain/swap/build.gradle.kts @@ -34,4 +34,5 @@ dependencies { testImplementation(deps.test.junit5) testImplementation(deps.test.truth) testImplementation(deps.test.mockk) + testImplementation(deps.test.coroutine) } \ No newline at end of file diff --git a/domain/swap/models/src/main/java/com/tangem/domain/swap/models/SwapCurrencies.kt b/domain/swap/models/src/main/java/com/tangem/domain/swap/models/SwapCurrencies.kt index 4eee1933a8..20de755884 100644 --- a/domain/swap/models/src/main/java/com/tangem/domain/swap/models/SwapCurrencies.kt +++ b/domain/swap/models/src/main/java/com/tangem/domain/swap/models/SwapCurrencies.kt @@ -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, val unavailable: List, val isAfterSearch: Boolean, + val availableForSwap: List = emptyList(), ) /** diff --git a/domain/swap/src/main/java/com/tangem/domain/swap/usecase/GetSwapSupportedPairsUseCase.kt b/domain/swap/src/main/java/com/tangem/domain/swap/usecase/GetSwapSupportedPairsUseCase.kt index fa54d42d79..5f610394ca 100644 --- a/domain/swap/src/main/java/com/tangem/domain/swap/usecase/GetSwapSupportedPairsUseCase.kt +++ b/domain/swap/src/main/java/com/tangem/domain/swap/usecase/GetSwapSupportedPairsUseCase.kt @@ -24,11 +24,13 @@ class GetSwapSupportedPairsUseCase( filterProviderTypes: List, 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, + allowedProviderTypes: List, ): 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, ) } } \ No newline at end of file diff --git a/domain/swap/src/test/kotlin/com/tangem/domain/swap/usecase/GetSwapSupportedPairsUseCaseTest.kt b/domain/swap/src/test/kotlin/com/tangem/domain/swap/usecase/GetSwapSupportedPairsUseCaseTest.kt new file mode 100644 index 0000000000..e64191b8f1 --- /dev/null +++ b/domain/swap/src/test/kotlin/com/tangem/domain/swap/usecase/GetSwapSupportedPairsUseCaseTest.kt @@ -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) = 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(relaxed = true).also { currency -> + every { currency.id } returns id + every { currency.network.transactionExtrasType } returns txExtras + } + } + } +} \ No newline at end of file diff --git a/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/SignUseCaseTest.kt b/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/SignUseCaseTest.kt index 4621315e38..d619014b2f 100644 --- a/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/SignUseCaseTest.kt +++ b/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/SignUseCaseTest.kt @@ -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(), any()) } returns CompletionResult.Failure(error) @@ -86,7 +86,7 @@ internal class SignUseCaseTest { val signer: TransactionSigner = mockk() val publicKeySlot = slot() - 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>(), 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()) } } } \ No newline at end of file diff --git a/features/manage-tokens/impl/build.gradle.kts b/features/manage-tokens/impl/build.gradle.kts index f2e5b7170c..b0d78716c5 100644 --- a/features/manage-tokens/impl/build.gradle.kts +++ b/features/manage-tokens/impl/build.gradle.kts @@ -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) diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/choosetoken/DefaultChooseManagedTokensComponent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/choosetoken/DefaultChooseManagedTokensComponent.kt index 7b18b4dc84..236208bdb9 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/choosetoken/DefaultChooseManagedTokensComponent.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/choosetoken/DefaultChooseManagedTokensComponent.kt @@ -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 diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/choosetoken/entity/ChooseManageTokensBottomSheetConfig.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/choosetoken/entity/ChooseManageTokensBottomSheetConfig.kt index 35d7732ad6..8d9ad27495 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/choosetoken/entity/ChooseManageTokensBottomSheetConfig.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/choosetoken/entity/ChooseManageTokensBottomSheetConfig.kt @@ -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() } \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/choosetoken/model/ChooseManagedTokensModel.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/choosetoken/model/ChooseManagedTokensModel.kt index 1d6522b7b1..e12ebd40ef 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/choosetoken/model/ChooseManagedTokensModel.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/choosetoken/model/ChooseManagedTokensModel.kt @@ -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 { + 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(), diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/model/SwapSelectTokensModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/model/SwapSelectTokensModel.kt index 876a6aa1dc..697f08be5a 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/model/SwapSelectTokensModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/model/SwapSelectTokensModel.kt @@ -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, ), diff --git a/features/swap-v2/api/src/main/java/com/tangem/features/swap/v2/api/choosetoken/SwapChooseTokenNetworkComponent.kt b/features/swap-v2/api/src/main/java/com/tangem/features/swap/v2/api/choosetoken/SwapChooseTokenNetworkComponent.kt index 8fa0dc468d..6ed63ebdc1 100644 --- a/features/swap-v2/api/src/main/java/com/tangem/features/swap/v2/api/choosetoken/SwapChooseTokenNetworkComponent.kt +++ b/features/swap-v2/api/src/main/java/com/tangem/features/swap/v2/api/choosetoken/SwapChooseTokenNetworkComponent.kt @@ -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 diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/entity/SwapAmountUM.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/entity/SwapAmountUM.kt index 41e06a2c94..9462e334c9 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/entity/SwapAmountUM.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/entity/SwapAmountUM.kt @@ -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 { diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/entity/SwapChooseTokenNetworkUM.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/entity/SwapChooseTokenNetworkUM.kt index 7940f4a904..f7fe58c7dc 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/entity/SwapChooseTokenNetworkUM.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/entity/SwapChooseTokenNetworkUM.kt @@ -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, diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/model/SwapChooseTokenFactory.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/model/SwapChooseTokenFactory.kt index 5a1d8e0eb2..ac8cdce503 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/model/SwapChooseTokenFactory.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/model/SwapChooseTokenFactory.kt @@ -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() } + } + } + } } \ No newline at end of file diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/model/SwapChooseTokenNetworkModel.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/model/SwapChooseTokenNetworkModel.kt index 767c44c058..e81941f470 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/model/SwapChooseTokenNetworkModel.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/model/SwapChooseTokenNetworkModel.kt @@ -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, ), ) } diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/model/transformers/SwapChooseContentStateTransformer.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/model/transformers/SwapChooseContentStateTransformer.kt index 438a0673b8..5e1a1c80f0 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/model/transformers/SwapChooseContentStateTransformer.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/model/transformers/SwapChooseContentStateTransformer.kt @@ -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 { 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, diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/analytics/SendWithSwapAnalyticEvents.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/analytics/SendWithSwapAnalyticEvents.kt index dafce5eca8..bcff04d0bf 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/analytics/SendWithSwapAnalyticEvents.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/analytics/SendWithSwapAnalyticEvents.kt @@ -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(), diff --git a/features/swap-v2/impl/src/test/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/model/transformers/SwapChooseContentStateTransformerTest.kt b/features/swap-v2/impl/src/test/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/model/transformers/SwapChooseContentStateTransformerTest.kt new file mode 100644 index 0000000000..a98ef234bd --- /dev/null +++ b/features/swap-v2/impl/src/test/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/model/transformers/SwapChooseContentStateTransformerTest.kt @@ -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, + availableForSwap: List, + ): SwapCurrencies = SwapCurrencies.EMPTY.copy( + fromGroup = SwapCurrenciesGroup( + available = available, + unavailable = emptyList(), + isAfterSearch = false, + availableForSwap = availableForSwap, + ), + ) + + private fun swapCryptoCurrency(): SwapCryptoCurrency = SwapCryptoCurrency( + currencyStatus = CryptoCurrencyStatus(currency = mockk(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 = {}), + ), + ), + ) +} \ No newline at end of file diff --git a/features/swap/CLAUDE.md b/features/swap/CLAUDE.md index bec99d0844..cba9f1d342 100644 --- a/features/swap/CLAUDE.md +++ b/features/swap/CLAUDE.md @@ -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`. diff --git a/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapComponent.kt b/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapComponent.kt index b7b5ca195d..7629b2b397 100644 --- a/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapComponent.kt +++ b/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapComponent.kt @@ -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, diff --git a/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapFeatureToggles.kt b/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapFeatureToggles.kt index f3ea6a28c3..91abcfd896 100644 --- a/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapFeatureToggles.kt +++ b/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapFeatureToggles.kt @@ -9,4 +9,5 @@ interface SwapFeatureToggles { val isSwapRateExperienceEnabled: Boolean val isSwapPredefinedButtonsEnabled: Boolean val isExpressShareButtonEnabled: Boolean + val isSwapBestDexRateEnabled: Boolean } \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapPairLeast.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapPairLeast.kt index 166682b781..5d5fe50852 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapPairLeast.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapPairLeast.kt @@ -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 { return listOf(CEX, DEX, DEX_BRIDGE) diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt index 72c809308b..5b41f9ef63 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt @@ -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 { diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapFeatureToggles.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapFeatureToggles.kt index 79cfd4ab71..1babbc9152 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapFeatureToggles.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapFeatureToggles.kt @@ -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 } \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/SwapProviderResolver.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/SwapProviderResolver.kt new file mode 100644 index 0000000000..8db946ff18 --- /dev/null +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/SwapProviderResolver.kt @@ -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, + 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, + 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, + 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? { + 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 + } + } +} \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/SwapProviderStateBuilder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/SwapProviderStateBuilder.kt index cddf47c4a4..830a02a8f7 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/SwapProviderStateBuilder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/SwapProviderStateBuilder.kt @@ -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, 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 } \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/InitialCurrenciesResolver.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/InitialCurrenciesResolver.kt index a3bf3f6243..3ec7751778 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/InitialCurrenciesResolver.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/InitialCurrenciesResolver.kt @@ -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 { 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>, + ): 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) } /** diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt index ad918f5f78..b009006d5e 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt @@ -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 - @Suppress("LongParameterList", "LargeClass") @Stable @ModelScoped @@ -172,7 +170,7 @@ internal class SwapModel @Inject constructor( private val params = paramsContainer.require() - 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 { val selectedProviderEntry = state.filter { entry -> entry.key.providerId == selectedProviderId }.entries.firstOrNull() @@ -2257,17 +2243,6 @@ internal class SwapModel @Inject constructor( ) } - private fun Map.getLastLoadedSuccessStates(): SuccessLoadedSwapData { - return this.filter { entry -> entry.value is SwapState.QuotesLoadedState } - .mapValues { entry -> entry.value as SwapState.QuotesLoadedState } - } - - private fun Map.consideredProvidersStates(): Map { - 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 diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapProcessDataState.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapProcessDataState.kt index 44368feeef..3d87e38110 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapProcessDataState.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapProcessDataState.kt @@ -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 + 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.getLastLoadedSuccessStates(): SuccessLoadedSwapData { + return this.filter { entry -> entry.value is SwapState.QuotesLoadedState } + .mapValues { entry -> entry.value as SwapState.QuotesLoadedState } +} + +internal fun Map.consideredProvidersStates(): Map { + 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) + } } \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/ProviderState.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/ProviderState.kt index b36a3305f1..f5d8f4a5ca 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/ProviderState.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/ProviderState.kt @@ -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() diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/ProviderItem.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/ProviderItem.kt index c5ce3a57e2..f598e7fca5 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/ProviderItem.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/ProviderItem.kt @@ -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( diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/ProviderItemSimple.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/ProviderItemSimple.kt index f95a657f21..e242163b27 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/ProviderItemSimple.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/ProviderItemSimple.kt @@ -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) diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt index 3b95b37203..ab06eb4cb0 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt @@ -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, providersStates: Map, 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, 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, ) } } diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/DefaultInitialCurrenciesResolverTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/DefaultInitialCurrenciesResolverTest.kt index dc231612df..9177bda98e 100644 --- a/features/swap/impl/src/test/java/com/tangem/feature/swap/DefaultInitialCurrenciesResolverTest.kt +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/DefaultInitialCurrenciesResolverTest.kt @@ -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(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(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(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(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( diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderSwapButtonTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderSwapButtonTest.kt index 87b6790953..e608eb2f17 100644 --- a/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderSwapButtonTest.kt +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderSwapButtonTest.kt @@ -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, ) diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/converters/SwapProviderResolverTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/converters/SwapProviderResolverTest.kt new file mode 100644 index 0000000000..b723105404 --- /dev/null +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/converters/SwapProviderResolverTest.kt @@ -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 { + every { decimals } returns 6 + } + val swapStatus = mockk { + every { this@mockk.currency } returns currency + } + val fromInfo = mockk { + every { amountFiat } returns BigDecimal(fromFiat) + } + val toInfo = mockk { + 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 { every { shouldShowWarning() } returns priceImpactWarning } + } + } +} \ No newline at end of file diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/converters/SwapProviderStateBuilderTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/converters/SwapProviderStateBuilderTest.kt index 1e93944a6c..832725438b 100644 --- a/features/swap/impl/src/test/java/com/tangem/feature/swap/converters/SwapProviderStateBuilderTest.kt +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/converters/SwapProviderStateBuilderTest.kt @@ -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 { diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/model/SwapModelTestBase.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/model/SwapModelTestBase.kt index b7056e4aa5..838c392874 100644 --- a/features/swap/impl/src/test/java/com/tangem/feature/swap/model/SwapModelTestBase.kt +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/model/SwapModelTestBase.kt @@ -125,7 +125,7 @@ internal abstract class SwapModelTestBase { protected fun createParams(): SwapComponent.Params = SwapComponent.Params( userWalletId = userWalletId, - cryptoCurrency = null, + fromCryptoCurrency = null, screenSource = "Test", ) diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/express/PreviewEmptyExpressTransactionsComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/express/PreviewEmptyExpressTransactionsComponent.kt index dbba5c5cf0..0567ebae39 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/express/PreviewEmptyExpressTransactionsComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/express/PreviewEmptyExpressTransactionsComponent.kt @@ -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 = "", diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt index a0f44abadc..3269fe17d2 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt @@ -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, diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt index 1dc10278a5..888788f83f 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt @@ -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, diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt index 925d0d9ec3..47d675cd6b 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt @@ -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, ), diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsOnrampTransactionStateConverter.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsOnrampTransactionStateConverter.kt index 89fd4d64b8..1a72f86552 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsOnrampTransactionStateConverter.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsOnrampTransactionStateConverter.kt @@ -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 -> diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSwapTransactionsStateConverter.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSwapTransactionsStateConverter.kt index 78b32deba5..8a6ded8831 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSwapTransactionsStateConverter.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSwapTransactionsStateConverter.kt @@ -59,7 +59,7 @@ internal class TokenDetailsSwapTransactionsStateConverter( fun convert( savedTransactions: List, - accountStatuses: Map, + accountStatuses: Map>, ): PersistentList { val result = mutableListOf() @@ -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>, + ): Pair { + 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 -> diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/ExchangeStatusFactory.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/ExchangeStatusFactory.kt index 924b9619b7..a3d5d3af06 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/ExchangeStatusFactory.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/ExchangeStatusFactory.kt @@ -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?, - accountStatuses: Map, + accountStatuses: Map>, ): PersistentList { if (savedTransactions == null) { return persistentListOf() @@ -231,7 +230,7 @@ internal class ExchangeStatusFactory @AssistedInject constructor( } } - private suspend fun Map.getStatuses(): Map { + private suspend fun Set>.getStatuses(): Map> { 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 diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/express/ExpressStatusBottomSheetStateProvider.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/express/ExpressStatusBottomSheetStateProvider.kt index cc256b1136..24243614fe 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/express/ExpressStatusBottomSheetStateProvider.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/express/ExpressStatusBottomSheetStateProvider.kt @@ -54,12 +54,14 @@ class ExpressStatusBottomSheetStateProvider : PreviewParameterProvider