From 298e7a5565f32910e045e178bb1b4b43d4c3611a Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 16 Jun 2026 18:10:00 +0400 Subject: [PATCH] Updated on 2026-08-14 --- .../tangem/tap/routing/utils/ChildFactory.kt | 5 +- .../com/tangem/common/routing/AppRoute.kt | 7 +- .../ui/markets/action/TokenActionsHandler.kt | 2 +- domain/swap/build.gradle.kts | 1 + .../domain/swap/models/SwapCurrencies.kt | 3 + .../usecase/GetSwapSupportedPairsUseCase.kt | 42 +++- .../GetSwapSupportedPairsUseCaseTest.kt | 190 ++++++++++++++++++ features/manage-tokens/impl/build.gradle.kts | 2 + .../DefaultChooseManagedTokensComponent.kt | 9 + .../ChooseManageTokensBottomSheetConfig.kt | 4 + .../model/ChooseManagedTokensModel.kt | 69 +++++++ .../swap/model/SwapSelectTokensModel.kt | 2 +- .../SwapChooseTokenNetworkComponent.kt | 1 + .../entity/SwapChooseTokenNetworkUM.kt | 8 + .../model/SwapChooseTokenFactory.kt | 20 ++ .../model/SwapChooseTokenNetworkModel.kt | 13 +- .../SwapChooseContentStateTransformer.kt | 20 +- .../analytics/SendWithSwapAnalyticEvents.kt | 13 ++ .../SwapChooseContentStateTransformerTest.kt | 80 ++++++++ features/swap/CLAUDE.md | 7 +- .../com/tangem/features/swap/SwapComponent.kt | 5 +- .../swap/model/InitialCurrenciesResolver.kt | 43 +++- .../tangem/feature/swap/model/SwapModel.kt | 7 +- .../DefaultInitialCurrenciesResolverTest.kt | 155 ++++++++++++++ .../feature/swap/model/SwapModelTestBase.kt | 2 +- .../tangempay/model/TangemPayCardPageModel.kt | 4 +- .../tangempay/model/TangemPayDetailsModel.kt | 8 +- .../tokendetails/model/TokenDetailsModel.kt | 6 +- .../WalletCurrencyActionsClickIntents.kt | 2 +- 29 files changed, 687 insertions(+), 43 deletions(-) create mode 100644 domain/swap/src/test/kotlin/com/tangem/domain/swap/usecase/GetSwapSupportedPairsUseCaseTest.kt create mode 100644 features/swap-v2/impl/src/test/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/model/transformers/SwapChooseContentStateTransformerTest.kt 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/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/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/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/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 e824992d30..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 @@ -170,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 @@ -407,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 @@ -2316,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/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/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/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/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletCurrencyActionsClickIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletCurrencyActionsClickIntents.kt index dfa746b3dd..f05035e3ad 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletCurrencyActionsClickIntents.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletCurrencyActionsClickIntents.kt @@ -666,7 +666,7 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( private fun navigateToSwap(cryptoCurrencyStatus: CryptoCurrencyStatus, userWalletId: UserWalletId) { appRouter.push( AppRoute.Swap( - cryptoCurrency = cryptoCurrencyStatus.currency, + fromCryptoCurrency = cryptoCurrencyStatus.currency, userWalletId = userWalletId, screenSource = AnalyticsParam.ScreensSources.LongTap.value, ),