Updated on 2026-08-14
This commit is contained in:
parent
2e8d0bc03b
commit
298e7a5565
29 changed files with 687 additions and 43 deletions
|
|
@ -326,10 +326,11 @@ internal class ChildFactory @Inject constructor(
|
||||||
createComponentChild(
|
createComponentChild(
|
||||||
context = context,
|
context = context,
|
||||||
params = SwapComponent.Params(
|
params = SwapComponent.Params(
|
||||||
cryptoCurrency = route.cryptoCurrency,
|
fromCryptoCurrency = route.fromCryptoCurrency,
|
||||||
|
toCryptoCurrency = route.toCryptoCurrency,
|
||||||
userWalletId = route.userWalletId,
|
userWalletId = route.userWalletId,
|
||||||
screenSource = route.screenSource,
|
screenSource = route.screenSource,
|
||||||
currencyPosition = when (route.currencyPosition) {
|
fromCurrencyPosition = when (route.fromCurrencyPosition) {
|
||||||
AppRoute.Swap.CurrencyPosition.FROM -> SwapComponent.Params.CurrencyPosition.FROM
|
AppRoute.Swap.CurrencyPosition.FROM -> SwapComponent.Params.CurrencyPosition.FROM
|
||||||
AppRoute.Swap.CurrencyPosition.TO -> SwapComponent.Params.CurrencyPosition.TO
|
AppRoute.Swap.CurrencyPosition.TO -> SwapComponent.Params.CurrencyPosition.TO
|
||||||
AppRoute.Swap.CurrencyPosition.ANY -> SwapComponent.Params.CurrencyPosition.ANY
|
AppRoute.Swap.CurrencyPosition.ANY -> SwapComponent.Params.CurrencyPosition.ANY
|
||||||
|
|
|
||||||
|
|
@ -205,13 +205,14 @@ sealed class AppRoute(val path: String) : Route {
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Swap(
|
data class Swap(
|
||||||
val userWalletId: UserWalletId,
|
val userWalletId: UserWalletId,
|
||||||
val cryptoCurrency: CryptoCurrency? = null,
|
val fromCryptoCurrency: CryptoCurrency? = null,
|
||||||
val screenSource: String,
|
val screenSource: String,
|
||||||
val currencyPosition: CurrencyPosition = CurrencyPosition.ANY,
|
val fromCurrencyPosition: CurrencyPosition = CurrencyPosition.ANY,
|
||||||
val tangemPayInput: TangemPayInput? = null,
|
val tangemPayInput: TangemPayInput? = null,
|
||||||
|
val toCryptoCurrency: CryptoCurrency? = null,
|
||||||
) : AppRoute(
|
) : AppRoute(
|
||||||
path = "/swap" +
|
path = "/swap" +
|
||||||
"/${cryptoCurrency?.id?.value}" +
|
"/${fromCryptoCurrency?.id?.value}" +
|
||||||
"/${userWalletId.stringValue}",
|
"/${userWalletId.stringValue}",
|
||||||
) {
|
) {
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|
|
||||||
|
|
@ -136,7 +136,7 @@ class TokenActionsHandler @AssistedInject constructor(
|
||||||
private fun onExchangeClick(cryptoCurrencyData: CryptoCurrencyData) {
|
private fun onExchangeClick(cryptoCurrencyData: CryptoCurrencyData) {
|
||||||
router.push(
|
router.push(
|
||||||
AppRoute.Swap(
|
AppRoute.Swap(
|
||||||
cryptoCurrency = cryptoCurrencyData.status.currency,
|
fromCryptoCurrency = cryptoCurrencyData.status.currency,
|
||||||
userWalletId = cryptoCurrencyData.userWallet.walletId,
|
userWalletId = cryptoCurrencyData.userWallet.walletId,
|
||||||
screenSource = AnalyticsParam.ScreensSources.Markets.value,
|
screenSource = AnalyticsParam.ScreensSources.Markets.value,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -34,4 +34,5 @@ dependencies {
|
||||||
testImplementation(deps.test.junit5)
|
testImplementation(deps.test.junit5)
|
||||||
testImplementation(deps.test.truth)
|
testImplementation(deps.test.truth)
|
||||||
testImplementation(deps.test.mockk)
|
testImplementation(deps.test.mockk)
|
||||||
|
testImplementation(deps.test.coroutine)
|
||||||
}
|
}
|
||||||
|
|
@ -38,11 +38,14 @@ fun SwapCurrencies.getGroupWithDirection(swapDirection: SwapDirection): SwapCurr
|
||||||
* @param available list of available currencies to swap
|
* @param available list of available currencies to swap
|
||||||
* @param available list of unavailable currencies to swap
|
* @param available list of unavailable currencies to swap
|
||||||
* @param isAfterSearch flag indicates whether user searched token
|
* @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(
|
data class SwapCurrenciesGroup(
|
||||||
val available: List<SwapCryptoCurrency>,
|
val available: List<SwapCryptoCurrency>,
|
||||||
val unavailable: List<SwapCryptoCurrency>,
|
val unavailable: List<SwapCryptoCurrency>,
|
||||||
val isAfterSearch: Boolean,
|
val isAfterSearch: Boolean,
|
||||||
|
val availableForSwap: List<SwapCryptoCurrency> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -24,11 +24,13 @@ class GetSwapSupportedPairsUseCase(
|
||||||
filterProviderTypes: List<ExpressProviderType>,
|
filterProviderTypes: List<ExpressProviderType>,
|
||||||
swapTxType: SwapTxType,
|
swapTxType: SwapTxType,
|
||||||
) = Either.catch {
|
) = 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(
|
val pairs = swapRepositoryV2.getSupportedPairs(
|
||||||
userWallet = userWallet,
|
userWallet = userWallet,
|
||||||
initialCurrency = initialCurrency,
|
initialCurrency = initialCurrency,
|
||||||
cryptoCurrencyList = cryptoCurrencyList,
|
cryptoCurrencyList = cryptoCurrencyList,
|
||||||
filterProviderTypes = filterProviderTypes,
|
filterProviderTypes = emptyList(),
|
||||||
swapTxType = swapTxType,
|
swapTxType = swapTxType,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -42,12 +44,14 @@ class GetSwapSupportedPairsUseCase(
|
||||||
filteringCurrency = { it.from },
|
filteringCurrency = { it.from },
|
||||||
groupingCurrency = { it.to },
|
groupingCurrency = { it.to },
|
||||||
cryptoCurrencyList = filteredOutInitial,
|
cryptoCurrencyList = filteredOutInitial,
|
||||||
|
allowedProviderTypes = filterProviderTypes,
|
||||||
)
|
)
|
||||||
val toGroup = pairs.groupPairs(
|
val toGroup = pairs.groupPairs(
|
||||||
initialCurrency = initialCurrency,
|
initialCurrency = initialCurrency,
|
||||||
filteringCurrency = { it.to },
|
filteringCurrency = { it.to },
|
||||||
groupingCurrency = { it.from },
|
groupingCurrency = { it.from },
|
||||||
cryptoCurrencyList = filteredOutInitial,
|
cryptoCurrencyList = filteredOutInitial,
|
||||||
|
allowedProviderTypes = filterProviderTypes,
|
||||||
)
|
)
|
||||||
|
|
||||||
SwapCurrencies(
|
SwapCurrencies(
|
||||||
|
|
@ -61,8 +65,9 @@ class GetSwapSupportedPairsUseCase(
|
||||||
filteringCurrency: (SwapPairModel) -> CryptoCurrencyStatus,
|
filteringCurrency: (SwapPairModel) -> CryptoCurrencyStatus,
|
||||||
groupingCurrency: (SwapPairModel) -> CryptoCurrencyStatus,
|
groupingCurrency: (SwapPairModel) -> CryptoCurrencyStatus,
|
||||||
cryptoCurrencyList: List<CryptoCurrency>,
|
cryptoCurrencyList: List<CryptoCurrency>,
|
||||||
|
allowedProviderTypes: List<ExpressProviderType>,
|
||||||
): SwapCurrenciesGroup {
|
): SwapCurrenciesGroup {
|
||||||
val availableCryptoCurrencies = asSequence()
|
val candidates = asSequence()
|
||||||
.filter { pair ->
|
.filter { pair ->
|
||||||
filteringCurrency(pair).currency.id.rawCurrencyId == initialCurrency.id.rawCurrencyId
|
filteringCurrency(pair).currency.id.rawCurrencyId == initialCurrency.id.rawCurrencyId
|
||||||
}
|
}
|
||||||
|
|
@ -75,18 +80,32 @@ class GetSwapSupportedPairsUseCase(
|
||||||
.map { pair ->
|
.map { pair ->
|
||||||
val toCurrency = groupingCurrency(pair)
|
val toCurrency = groupingCurrency(pair)
|
||||||
val isTxExtrasSupported = toCurrency.currency.network.transactionExtrasType.isTxExtrasSupported()
|
val isTxExtrasSupported = toCurrency.currency.network.transactionExtrasType.isTxExtrasSupported()
|
||||||
val filteredProviders = if (isTxExtrasSupported) {
|
// Providers eligible for the current flow (e.g. Send with Swap): extra-id support + allowed type
|
||||||
pair.providers.filter { it.isExtraIdSupported }
|
val eligibleProviders = pair.providers
|
||||||
} else {
|
.filter { !isTxExtrasSupported || it.isExtraIdSupported }
|
||||||
pair.providers
|
.filter { allowedProviderTypes.isEmpty() || it.type in allowedProviderTypes }
|
||||||
}
|
// The pair has any provider => it can still be swapped in the regular Swap flow
|
||||||
SwapCryptoCurrency(toCurrency, filteredProviders)
|
SwapCryptoCurrency(toCurrency, eligibleProviders) to pair.providers.isNotEmpty()
|
||||||
}
|
}
|
||||||
.filter { it.providers.isNotEmpty() }
|
|
||||||
.toList()
|
.toList()
|
||||||
|
|
||||||
val unavailableCryptoCurrencies =
|
val availableCryptoCurrencies = candidates
|
||||||
cryptoCurrencyList - availableCryptoCurrencies.map { it.currencyStatus.currency }.toSet()
|
.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(
|
return SwapCurrenciesGroup(
|
||||||
available = availableCryptoCurrencies,
|
available = availableCryptoCurrencies,
|
||||||
|
|
@ -100,6 +119,7 @@ class GetSwapSupportedPairsUseCase(
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
isAfterSearch = false,
|
isAfterSearch = false,
|
||||||
|
availableForSwap = availableForSwapCryptoCurrencies,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,190 @@
|
||||||
|
package com.tangem.domain.swap.usecase
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import com.tangem.domain.express.models.ExpressProvider
|
||||||
|
import com.tangem.domain.express.models.ExpressProviderType
|
||||||
|
import com.tangem.domain.express.models.ExpressRateType
|
||||||
|
import com.tangem.domain.models.currency.CryptoCurrency
|
||||||
|
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||||
|
import com.tangem.domain.models.network.Network
|
||||||
|
import com.tangem.domain.models.wallet.UserWallet
|
||||||
|
import com.tangem.domain.swap.SwapErrorResolver
|
||||||
|
import com.tangem.domain.swap.SwapRepositoryV2
|
||||||
|
import com.tangem.domain.swap.models.SwapPairModel
|
||||||
|
import com.tangem.domain.swap.models.SwapTxType
|
||||||
|
import io.mockk.clearMocks
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.jupiter.api.BeforeEach
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.api.TestInstance
|
||||||
|
|
||||||
|
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||||
|
internal class GetSwapSupportedPairsUseCaseTest {
|
||||||
|
|
||||||
|
private val swapRepositoryV2: SwapRepositoryV2 = mockk()
|
||||||
|
private val swapErrorResolver: SwapErrorResolver = mockk()
|
||||||
|
|
||||||
|
private val useCase = GetSwapSupportedPairsUseCase(
|
||||||
|
swapRepositoryV2 = swapRepositoryV2,
|
||||||
|
swapErrorResolver = swapErrorResolver,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val initialCurrency = createCurrency("initial")
|
||||||
|
private val cexCurrency = createCurrency("cex")
|
||||||
|
private val dexOnlyCurrency = createCurrency("dex-only")
|
||||||
|
private val unavailableCurrency = createCurrency("unavailable")
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
fun setup() {
|
||||||
|
clearMocks(swapRepositoryV2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN cex, dex-only and missing currencies WHEN invoke THEN split into three buckets`() = runTest {
|
||||||
|
// Arrange
|
||||||
|
val pairs = listOf(
|
||||||
|
createPair(from = initialCurrency, to = cexCurrency, providers = listOf(cexProvider)),
|
||||||
|
createPair(from = initialCurrency, to = dexOnlyCurrency, providers = listOf(dexProvider)),
|
||||||
|
// unavailableCurrency intentionally has no pair at all
|
||||||
|
)
|
||||||
|
coEvery {
|
||||||
|
swapRepositoryV2.getSupportedPairs(any(), any(), any(), any(), any())
|
||||||
|
} returns pairs
|
||||||
|
|
||||||
|
// Act
|
||||||
|
val result = useCase.invoke(
|
||||||
|
userWallet = userWallet,
|
||||||
|
initialCurrency = initialCurrency,
|
||||||
|
cryptoCurrencyList = listOf(initialCurrency, cexCurrency, dexOnlyCurrency, unavailableCurrency),
|
||||||
|
filterProviderTypes = listOf(ExpressProviderType.CEX),
|
||||||
|
swapTxType = SwapTxType.SendWithSwap,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
val fromGroup = result.getOrNull()!!.fromGroup
|
||||||
|
assertThat(fromGroup.available.map { it.currencyStatus.currency }).containsExactly(cexCurrency)
|
||||||
|
assertThat(fromGroup.availableForSwap.map { it.currencyStatus.currency }).containsExactly(dexOnlyCurrency)
|
||||||
|
assertThat(fromGroup.unavailable.map { it.currencyStatus.currency }).containsExactly(unavailableCurrency)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN dex pair AND no type restriction WHEN invoke THEN currency is available not availableForSwap`() =
|
||||||
|
runTest {
|
||||||
|
// Arrange — allowedProviderTypes empty means any provider type is eligible for the current flow
|
||||||
|
val pairs = listOf(
|
||||||
|
createPair(from = initialCurrency, to = dexOnlyCurrency, providers = listOf(dexProvider)),
|
||||||
|
)
|
||||||
|
coEvery {
|
||||||
|
swapRepositoryV2.getSupportedPairs(any(), any(), any(), any(), any())
|
||||||
|
} returns pairs
|
||||||
|
|
||||||
|
// Act
|
||||||
|
val result = useCase.invoke(
|
||||||
|
userWallet = userWallet,
|
||||||
|
initialCurrency = initialCurrency,
|
||||||
|
cryptoCurrencyList = listOf(initialCurrency, dexOnlyCurrency),
|
||||||
|
filterProviderTypes = emptyList(),
|
||||||
|
swapTxType = SwapTxType.SendWithSwap,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
val fromGroup = result.getOrNull()!!.fromGroup
|
||||||
|
assertThat(fromGroup.available.map { it.currencyStatus.currency }).containsExactly(dexOnlyCurrency)
|
||||||
|
assertThat(fromGroup.availableForSwap).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN memo network with provider without extra-id WHEN invoke THEN currency is availableForSwap`() = runTest {
|
||||||
|
// Arrange — to-network requires extra id, but the only CEX provider doesn't support it
|
||||||
|
val memoCurrency = createCurrency(rawId = "memo", txExtras = Network.TransactionExtrasType.MEMO)
|
||||||
|
val pairs = listOf(
|
||||||
|
createPair(from = initialCurrency, to = memoCurrency, providers = listOf(cexProviderNoExtraId)),
|
||||||
|
)
|
||||||
|
coEvery {
|
||||||
|
swapRepositoryV2.getSupportedPairs(any(), any(), any(), any(), any())
|
||||||
|
} returns pairs
|
||||||
|
|
||||||
|
// Act
|
||||||
|
val result = useCase.invoke(
|
||||||
|
userWallet = userWallet,
|
||||||
|
initialCurrency = initialCurrency,
|
||||||
|
cryptoCurrencyList = listOf(initialCurrency, memoCurrency),
|
||||||
|
filterProviderTypes = listOf(ExpressProviderType.CEX),
|
||||||
|
swapTxType = SwapTxType.SendWithSwap,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
val fromGroup = result.getOrNull()!!.fromGroup
|
||||||
|
assertThat(fromGroup.available).isEmpty()
|
||||||
|
assertThat(fromGroup.availableForSwap.map { it.currencyStatus.currency }).containsExactly(memoCurrency)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN pair with both cex and dex providers WHEN invoke THEN currency is available not availableForSwap`() =
|
||||||
|
runTest {
|
||||||
|
// Arrange
|
||||||
|
val pairs = listOf(
|
||||||
|
createPair(from = initialCurrency, to = cexCurrency, providers = listOf(cexProvider, dexProvider)),
|
||||||
|
)
|
||||||
|
coEvery {
|
||||||
|
swapRepositoryV2.getSupportedPairs(any(), any(), any(), any(), any())
|
||||||
|
} returns pairs
|
||||||
|
|
||||||
|
// Act
|
||||||
|
val result = useCase.invoke(
|
||||||
|
userWallet = userWallet,
|
||||||
|
initialCurrency = initialCurrency,
|
||||||
|
cryptoCurrencyList = listOf(initialCurrency, cexCurrency),
|
||||||
|
filterProviderTypes = listOf(ExpressProviderType.CEX),
|
||||||
|
swapTxType = SwapTxType.SendWithSwap,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
val fromGroup = result.getOrNull()!!.fromGroup
|
||||||
|
assertThat(fromGroup.available.map { it.currencyStatus.currency }).containsExactly(cexCurrency)
|
||||||
|
assertThat(fromGroup.availableForSwap).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createPair(from: CryptoCurrency, to: CryptoCurrency, providers: List<ExpressProvider>) = SwapPairModel(
|
||||||
|
from = CryptoCurrencyStatus(currency = from, value = mockk(relaxed = true)),
|
||||||
|
to = CryptoCurrencyStatus(currency = to, value = mockk(relaxed = true)),
|
||||||
|
providers = providers,
|
||||||
|
)
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
val userWallet: UserWallet = mockk(relaxed = true)
|
||||||
|
|
||||||
|
val cexProvider = createProvider("cex-1", ExpressProviderType.CEX, isExtraIdSupported = true)
|
||||||
|
val cexProviderNoExtraId = createProvider("cex-2", ExpressProviderType.CEX, isExtraIdSupported = false)
|
||||||
|
val dexProvider = createProvider("dex-1", ExpressProviderType.DEX, isExtraIdSupported = false)
|
||||||
|
|
||||||
|
fun createProvider(id: String, type: ExpressProviderType, isExtraIdSupported: Boolean) = ExpressProvider(
|
||||||
|
providerId = id,
|
||||||
|
rateTypes = listOf(ExpressRateType.Float),
|
||||||
|
name = id,
|
||||||
|
type = type,
|
||||||
|
imageLarge = "",
|
||||||
|
termsOfUse = null,
|
||||||
|
privacyPolicy = null,
|
||||||
|
slippage = null,
|
||||||
|
isExtraIdSupported = isExtraIdSupported,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun createCurrency(
|
||||||
|
rawId: String,
|
||||||
|
txExtras: Network.TransactionExtrasType = Network.TransactionExtrasType.NONE,
|
||||||
|
): CryptoCurrency {
|
||||||
|
val id: CryptoCurrency.ID = mockk {
|
||||||
|
every { rawCurrencyId } returns CryptoCurrency.RawID(rawId)
|
||||||
|
every { rawNetworkId } returns rawId
|
||||||
|
}
|
||||||
|
return mockk<CryptoCurrency>(relaxed = true).also { currency ->
|
||||||
|
every { currency.id } returns id
|
||||||
|
every { currency.network.transactionExtrasType } returns txExtras
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,7 @@ dependencies {
|
||||||
/* Project - API */
|
/* Project - API */
|
||||||
implementation(projects.features.manageTokens.api)
|
implementation(projects.features.manageTokens.api)
|
||||||
implementation(projects.features.swapV2.api)
|
implementation(projects.features.swapV2.api)
|
||||||
|
implementation(projects.features.commonFeatures.api)
|
||||||
|
|
||||||
/* Project - Core */
|
/* Project - Core */
|
||||||
implementation(projects.core.decompose)
|
implementation(projects.core.decompose)
|
||||||
|
|
@ -39,6 +40,7 @@ dependencies {
|
||||||
implementation(projects.domain.wallets)
|
implementation(projects.domain.wallets)
|
||||||
implementation(projects.domain.wallets.models)
|
implementation(projects.domain.wallets.models)
|
||||||
implementation(projects.domain.swap.models)
|
implementation(projects.domain.swap.models)
|
||||||
|
implementation(projects.domain.markets.models)
|
||||||
implementation(projects.domain.notifications)
|
implementation(projects.domain.notifications)
|
||||||
implementation(projects.domain.dynamicAddresses)
|
implementation(projects.domain.dynamicAddresses)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import com.tangem.core.decompose.context.AppComponentContext
|
||||||
import com.tangem.core.decompose.context.childByContext
|
import com.tangem.core.decompose.context.childByContext
|
||||||
import com.tangem.core.decompose.model.getOrCreateModel
|
import com.tangem.core.decompose.model.getOrCreateModel
|
||||||
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
|
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.entity.ChooseManageTokensBottomSheetConfig
|
||||||
import com.tangem.features.managetokens.choosetoken.model.ChooseManagedTokensModel
|
import com.tangem.features.managetokens.choosetoken.model.ChooseManagedTokensModel
|
||||||
import com.tangem.features.managetokens.choosetoken.ui.ChooseManagedTokenContent
|
import com.tangem.features.managetokens.choosetoken.ui.ChooseManagedTokenContent
|
||||||
|
|
@ -28,6 +29,7 @@ internal class DefaultChooseManagedTokensComponent @AssistedInject constructor(
|
||||||
@Assisted private val params: ChooseManagedTokensComponent.Params,
|
@Assisted private val params: ChooseManagedTokensComponent.Params,
|
||||||
private val swapChooseTokenNetworkFactory: SwapChooseTokenNetworkComponent.Factory,
|
private val swapChooseTokenNetworkFactory: SwapChooseTokenNetworkComponent.Factory,
|
||||||
private val swapChooseTokenNetworkTrigger: SwapChooseTokenNetworkTrigger,
|
private val swapChooseTokenNetworkTrigger: SwapChooseTokenNetworkTrigger,
|
||||||
|
private val addToPortfolioComponentFactory: AddToPortfolioComponent.Factory,
|
||||||
) : ChooseManagedTokensComponent, AppComponentContext by context {
|
) : ChooseManagedTokensComponent, AppComponentContext by context {
|
||||||
|
|
||||||
private val model: ChooseManagedTokensModel = getOrCreateModel(params)
|
private val model: ChooseManagedTokensModel = getOrCreateModel(params)
|
||||||
|
|
@ -76,8 +78,15 @@ internal class DefaultChooseManagedTokensComponent @AssistedInject constructor(
|
||||||
params.callback?.onResult() ?: router.pop()
|
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
|
@AssistedFactory
|
||||||
|
|
|
||||||
|
|
@ -16,4 +16,8 @@ internal sealed class ChooseManageTokensBottomSheetConfig {
|
||||||
val token: ManagedCryptoCurrency.Token,
|
val token: ManagedCryptoCurrency.Token,
|
||||||
val isSearchedToken: Boolean,
|
val isSearchedToken: Boolean,
|
||||||
) : ChooseManageTokensBottomSheetConfig()
|
) : ChooseManageTokensBottomSheetConfig()
|
||||||
|
|
||||||
|
/** Add the swap target token to the portfolio before opening the regular Swap screen. */
|
||||||
|
@Serializable
|
||||||
|
data object AddToPortfolioBottomSheetConfig : ChooseManageTokensBottomSheetConfig()
|
||||||
}
|
}
|
||||||
|
|
@ -3,6 +3,8 @@ package com.tangem.features.managetokens.choosetoken.model
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import com.arkivanov.decompose.router.slot.SlotNavigation
|
import com.arkivanov.decompose.router.slot.SlotNavigation
|
||||||
import com.arkivanov.decompose.router.slot.activate
|
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.NotificationId
|
||||||
import com.tangem.common.ui.notifications.NotificationUM
|
import com.tangem.common.ui.notifications.NotificationUM
|
||||||
import com.tangem.core.analytics.api.AnalyticsEventHandler
|
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.model.ParamsContainer
|
||||||
import com.tangem.core.decompose.navigation.Router
|
import com.tangem.core.decompose.navigation.Router
|
||||||
import com.tangem.core.decompose.ui.UiMessageSender
|
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.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.consumedEvent
|
||||||
import com.tangem.core.ui.event.triggeredEvent
|
import com.tangem.core.ui.event.triggeredEvent
|
||||||
import com.tangem.core.ui.extensions.resourceReference
|
import com.tangem.core.ui.extensions.resourceReference
|
||||||
|
|
@ -55,10 +63,19 @@ internal class ChooseManagedTokensModel @Inject constructor(
|
||||||
paramsContainer: ParamsContainer,
|
paramsContainer: ParamsContainer,
|
||||||
manageTokensUseCasesFacadeFactory: ManageTokensUseCasesFacade.Factory,
|
manageTokensUseCasesFacadeFactory: ManageTokensUseCasesFacade.Factory,
|
||||||
manageTokensListManagerFactory: ManageTokensListManager.Factory,
|
manageTokensListManagerFactory: ManageTokensListManager.Factory,
|
||||||
|
addToPortfolioManagerFactory: AddToPortfolioManager.Factory,
|
||||||
) : Model() {
|
) : Model() {
|
||||||
|
|
||||||
private val params: ChooseManagedTokensComponent.Params = paramsContainer.require()
|
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 manageTokensMode = ManageTokensMode.Account(params.userWalletId)
|
||||||
|
|
||||||
private val useCasesFacade: ManageTokensUseCasesFacade = manageTokensUseCasesFacadeFactory
|
private val useCasesFacade: ManageTokensUseCasesFacade = manageTokensUseCasesFacadeFactory
|
||||||
|
|
@ -97,11 +114,63 @@ internal class ChooseManagedTokensModel @Inject constructor(
|
||||||
|
|
||||||
observeSearchQueryChanges()
|
observeSearchQueryChanges()
|
||||||
|
|
||||||
|
addToPortfolioManager.onSuccessAdded.receiveAsFlow()
|
||||||
|
.onEach { result -> navigateToSwap(toCryptoCurrency = result.addedCurrency.currency) }
|
||||||
|
.launchIn(modelScope)
|
||||||
|
addToPortfolioManager.onDismiss.receiveAsFlow()
|
||||||
|
.onEach { bottomSheetNavigation.dismiss() }
|
||||||
|
.launchIn(modelScope)
|
||||||
|
|
||||||
modelScope.launch {
|
modelScope.launch {
|
||||||
manageTokensListManager.launchPagination(isCollapsed = false)
|
manageTokensListManager.launchPagination(isCollapsed = false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoked from the Send-with-Swap "Swap token" notice when the token is only swappable in the regular
|
||||||
|
* Swap flow. If the token isn't added to the wallet yet, shows the add-to-portfolio bottom sheet first;
|
||||||
|
* otherwise opens the Swap screen directly with [targetCurrency] pre-selected as TO.
|
||||||
|
*/
|
||||||
|
fun onSwapTokenClick(token: ManagedCryptoCurrency.Token, targetCurrency: CryptoCurrency) {
|
||||||
|
if (token.isAdded) {
|
||||||
|
navigateToSwap(toCryptoCurrency = targetCurrency)
|
||||||
|
} else {
|
||||||
|
addToPortfolioManager.setTokenNetworks(token.toMarketNetworks())
|
||||||
|
addToPortfolioManager.setTokenParams(token.toRawMarketToken())
|
||||||
|
bottomSheetNavigation.activate(ChooseManageTokensBottomSheetConfig.AddToPortfolioBottomSheetConfig)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun navigateToSwap(toCryptoCurrency: CryptoCurrency) {
|
||||||
|
bottomSheetNavigation.dismiss()
|
||||||
|
router.push(
|
||||||
|
AppRoute.Swap(
|
||||||
|
userWalletId = params.userWalletId,
|
||||||
|
fromCryptoCurrency = params.initialCurrency,
|
||||||
|
toCryptoCurrency = toCryptoCurrency,
|
||||||
|
screenSource = AnalyticsParam.ScreensSources.Send.value,
|
||||||
|
fromCurrencyPosition = AppRoute.Swap.CurrencyPosition.FROM,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ManagedCryptoCurrency.Token.toRawMarketToken(): RawMarketToken = RawMarketToken(
|
||||||
|
id = CryptoCurrency.RawID(id.value),
|
||||||
|
name = name,
|
||||||
|
symbol = symbol,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun ManagedCryptoCurrency.Token.toMarketNetworks(): List<TokenMarketInfo.Network> {
|
||||||
|
return availableNetworks.map { sourceNetwork ->
|
||||||
|
TokenMarketInfo.Network(
|
||||||
|
networkId = sourceNetwork.network.rawId,
|
||||||
|
isExchangeable = false,
|
||||||
|
contractAddress = (sourceNetwork as? ManagedCryptoCurrency.SourceNetwork.Default)?.contractAddress,
|
||||||
|
decimalCount = sourceNetwork.decimals,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun createReadContentModel(): ChooseManagedTokenUM {
|
private fun createReadContentModel(): ChooseManagedTokenUM {
|
||||||
return ChooseManagedTokenUM(
|
return ChooseManagedTokenUM(
|
||||||
notificationUM = getNotification(),
|
notificationUM = getNotification(),
|
||||||
|
|
|
||||||
|
|
@ -114,7 +114,7 @@ internal class SwapSelectTokensModel @Inject constructor(
|
||||||
|
|
||||||
router.push(
|
router.push(
|
||||||
route = AppRoute.Swap(
|
route = AppRoute.Swap(
|
||||||
cryptoCurrency = requireNotNull(fromCurrencyStatus.value).currency,
|
fromCryptoCurrency = requireNotNull(fromCurrencyStatus.value).currency,
|
||||||
userWalletId = params.userWalletId,
|
userWalletId = params.userWalletId,
|
||||||
screenSource = AnalyticsParam.ScreensSources.Main.value,
|
screenSource = AnalyticsParam.ScreensSources.Main.value,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ interface SwapChooseTokenNetworkComponent : ComposableBottomSheetComponent {
|
||||||
val isSearchedToken: Boolean,
|
val isSearchedToken: Boolean,
|
||||||
val onDismiss: () -> Unit,
|
val onDismiss: () -> Unit,
|
||||||
val onResult: (SwapCurrencies, CryptoCurrency) -> Unit,
|
val onResult: (SwapCurrencies, CryptoCurrency) -> Unit,
|
||||||
|
val onSwapClick: (CryptoCurrency) -> Unit,
|
||||||
)
|
)
|
||||||
|
|
||||||
interface Factory : ComponentFactory<Params, SwapChooseTokenNetworkComponent>
|
interface Factory : ComponentFactory<Params, SwapChooseTokenNetworkComponent>
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,14 @@ internal sealed class SwapChooseTokenNetworkContentUM : TangemBottomSheetConfigC
|
||||||
override val messageContent: MessageBottomSheetUM,
|
override val messageContent: MessageBottomSheetUM,
|
||||||
) : SwapChooseTokenNetworkContentUM()
|
) : 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(
|
data class Content(
|
||||||
override val messageContent: MessageBottomSheetUM,
|
override val messageContent: MessageBottomSheetUM,
|
||||||
val swapNetworks: ImmutableList<SwapChooseNetworkUM>,
|
val swapNetworks: ImmutableList<SwapChooseNetworkUM>,
|
||||||
|
|
|
||||||
|
|
@ -23,4 +23,24 @@ internal object SwapChooseTokenFactory {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getSwapAvailableMessage(tokenName: String, onSwapClick: () -> Unit): MessageBottomSheetUM {
|
||||||
|
return messageBottomSheetUM {
|
||||||
|
infoBlock {
|
||||||
|
icon(R.drawable.ic_alert_triangle_20) {
|
||||||
|
type = MessageBottomSheetUM.Icon.Type.Attention
|
||||||
|
backgroundType = MessageBottomSheetUM.Icon.BackgroundType.SameAsTint
|
||||||
|
}
|
||||||
|
title = resourceReference(
|
||||||
|
R.string.express_send_with_swap_not_supported_title,
|
||||||
|
wrappedList(tokenName),
|
||||||
|
)
|
||||||
|
body = resourceReference(R.string.express_send_with_swap_not_supported_text)
|
||||||
|
}
|
||||||
|
primaryButton {
|
||||||
|
text = resourceReference(R.string.express_send_with_swap_not_supported_button)
|
||||||
|
onClick { onSwapClick() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -99,12 +99,18 @@ internal class SwapChooseTokenNetworkModel @Inject constructor(
|
||||||
}
|
}
|
||||||
delay(MINIMUM_LOADING_TIME)
|
delay(MINIMUM_LOADING_TIME)
|
||||||
if (pairs.fromGroup.available.isEmpty()) {
|
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(
|
SendWithSwapAnalyticEvents.NoticeCanNotSwapToken(
|
||||||
fromToken = params.initialCurrency,
|
fromToken = params.initialCurrency,
|
||||||
toTokenSymbol = params.token.symbol,
|
toTokenSymbol = params.token.symbol,
|
||||||
),
|
)
|
||||||
)
|
}
|
||||||
|
analyticsEventHandler.send(analyticsEvent)
|
||||||
}
|
}
|
||||||
uiState.update(
|
uiState.update(
|
||||||
SwapChooseContentStateTransformer(
|
SwapChooseContentStateTransformer(
|
||||||
|
|
@ -112,6 +118,7 @@ internal class SwapChooseTokenNetworkModel @Inject constructor(
|
||||||
onNetworkClick = ::onSwapTokenClick,
|
onNetworkClick = ::onSwapTokenClick,
|
||||||
tokenName = params.token.name,
|
tokenName = params.token.name,
|
||||||
onDismiss = params.onDismiss,
|
onDismiss = params.onDismiss,
|
||||||
|
onSwapClick = params.onSwapClick,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import com.tangem.features.swap.v2.impl.choosetoken.fromSupported.entity.SwapCho
|
||||||
import com.tangem.features.swap.v2.impl.choosetoken.fromSupported.entity.SwapChooseTokenNetworkContentUM
|
import com.tangem.features.swap.v2.impl.choosetoken.fromSupported.entity.SwapChooseTokenNetworkContentUM
|
||||||
import com.tangem.features.swap.v2.impl.choosetoken.fromSupported.entity.SwapChooseTokenNetworkUM
|
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.getErrorMessage
|
||||||
|
import com.tangem.features.swap.v2.impl.choosetoken.fromSupported.model.SwapChooseTokenFactory.getSwapAvailableMessage
|
||||||
import com.tangem.lib.crypto.BlockchainUtils
|
import com.tangem.lib.crypto.BlockchainUtils
|
||||||
import com.tangem.utils.transformer.Transformer
|
import com.tangem.utils.transformer.Transformer
|
||||||
import kotlinx.collections.immutable.toPersistentList
|
import kotlinx.collections.immutable.toPersistentList
|
||||||
|
|
@ -21,6 +22,7 @@ internal class SwapChooseContentStateTransformer(
|
||||||
private val tokenName: String,
|
private val tokenName: String,
|
||||||
private val onNetworkClick: (SwapCurrencies, CryptoCurrency) -> Unit,
|
private val onNetworkClick: (SwapCurrencies, CryptoCurrency) -> Unit,
|
||||||
private val onDismiss: () -> Unit,
|
private val onDismiss: () -> Unit,
|
||||||
|
private val onSwapClick: (CryptoCurrency) -> Unit,
|
||||||
) : Transformer<SwapChooseTokenNetworkUM> {
|
) : Transformer<SwapChooseTokenNetworkUM> {
|
||||||
override fun transform(prevState: SwapChooseTokenNetworkUM): SwapChooseTokenNetworkUM {
|
override fun transform(prevState: SwapChooseTokenNetworkUM): SwapChooseTokenNetworkUM {
|
||||||
val swapNetworks = pairs.fromGroup.available.map { availableCurrency ->
|
val swapNetworks = pairs.fromGroup.available.map { availableCurrency ->
|
||||||
|
|
@ -51,16 +53,26 @@ internal class SwapChooseContentStateTransformer(
|
||||||
|
|
||||||
return prevState.copy(
|
return prevState.copy(
|
||||||
bottomSheetConfig = prevState.bottomSheetConfig.copy(
|
bottomSheetConfig = prevState.bottomSheetConfig.copy(
|
||||||
content = if (swapNetworks.isNotEmpty()) {
|
content = when {
|
||||||
SwapChooseTokenNetworkContentUM.Content(
|
swapNetworks.isNotEmpty() -> SwapChooseTokenNetworkContentUM.Content(
|
||||||
swapNetworks = swapNetworks,
|
swapNetworks = swapNetworks,
|
||||||
messageContent = getErrorMessage(
|
messageContent = getErrorMessage(
|
||||||
tokenName = tokenName,
|
tokenName = tokenName,
|
||||||
onDismiss = onDismiss,
|
onDismiss = onDismiss,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
} else {
|
// No networks for Send with Swap, but the pair is available in the regular Swap flow
|
||||||
SwapChooseTokenNetworkContentUM.Error(
|
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(
|
messageContent = getErrorMessage(
|
||||||
tokenName = tokenName,
|
tokenName = tokenName,
|
||||||
onDismiss = onDismiss,
|
onDismiss = onDismiss,
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,19 @@ internal sealed class SendWithSwapAnalyticEvents(
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Token can't be used in Send with Swap, but is available in the regular Swap flow */
|
||||||
|
data class NoticeSwapAvailable(
|
||||||
|
val fromToken: CryptoCurrency,
|
||||||
|
val toTokenSymbol: String,
|
||||||
|
) : SendWithSwapAnalyticEvents(
|
||||||
|
event = "Notice - Swap Available",
|
||||||
|
params = mapOf(
|
||||||
|
SEND_TOKEN to fromToken.symbol,
|
||||||
|
RECEIVE_TOKEN to toTokenSymbol,
|
||||||
|
SEND_BLOCKCHAIN to fromToken.network.name,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
data object NoticeFixedRate : SendWithSwapAnalyticEvents(
|
data object NoticeFixedRate : SendWithSwapAnalyticEvents(
|
||||||
event = "Notice - Fixed Rate",
|
event = "Notice - Fixed Rate",
|
||||||
params = emptyMap(),
|
params = emptyMap(),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
package com.tangem.features.swap.v2.impl.choosetoken.fromSupported.model.transformers
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
|
||||||
|
import com.tangem.domain.models.currency.CryptoCurrency
|
||||||
|
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||||
|
import com.tangem.domain.swap.models.SwapCryptoCurrency
|
||||||
|
import com.tangem.domain.swap.models.SwapCurrencies
|
||||||
|
import com.tangem.domain.swap.models.SwapCurrenciesGroup
|
||||||
|
import com.tangem.features.swap.v2.impl.choosetoken.fromSupported.entity.SwapChooseTokenNetworkContentUM
|
||||||
|
import com.tangem.features.swap.v2.impl.choosetoken.fromSupported.entity.SwapChooseTokenNetworkUM
|
||||||
|
import com.tangem.features.swap.v2.impl.choosetoken.fromSupported.model.SwapChooseTokenFactory
|
||||||
|
import io.mockk.mockk
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
internal class SwapChooseContentStateTransformerTest {
|
||||||
|
|
||||||
|
private val tokenName = "Shiba Inu"
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN no available but availableForSwap present WHEN transform THEN content is SwapAvailable`() {
|
||||||
|
// Arrange
|
||||||
|
val pairs = swapCurrencies(available = emptyList(), availableForSwap = listOf(swapCryptoCurrency()))
|
||||||
|
|
||||||
|
// Act
|
||||||
|
val result = transformer(pairs).transform(prevState())
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assertThat(result.bottomSheetConfig.content)
|
||||||
|
.isInstanceOf(SwapChooseTokenNetworkContentUM.SwapAvailable::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN no available and no availableForSwap WHEN transform THEN content is Error`() {
|
||||||
|
// Arrange
|
||||||
|
val pairs = swapCurrencies(available = emptyList(), availableForSwap = emptyList())
|
||||||
|
|
||||||
|
// Act
|
||||||
|
val result = transformer(pairs).transform(prevState())
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assertThat(result.bottomSheetConfig.content)
|
||||||
|
.isInstanceOf(SwapChooseTokenNetworkContentUM.Error::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun transformer(pairs: SwapCurrencies) = SwapChooseContentStateTransformer(
|
||||||
|
pairs = pairs,
|
||||||
|
tokenName = tokenName,
|
||||||
|
onNetworkClick = { _, _ -> },
|
||||||
|
onDismiss = {},
|
||||||
|
onSwapClick = {},
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun swapCurrencies(
|
||||||
|
available: List<SwapCryptoCurrency>,
|
||||||
|
availableForSwap: List<SwapCryptoCurrency>,
|
||||||
|
): SwapCurrencies = SwapCurrencies.EMPTY.copy(
|
||||||
|
fromGroup = SwapCurrenciesGroup(
|
||||||
|
available = available,
|
||||||
|
unavailable = emptyList(),
|
||||||
|
isAfterSearch = false,
|
||||||
|
availableForSwap = availableForSwap,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun swapCryptoCurrency(): SwapCryptoCurrency = SwapCryptoCurrency(
|
||||||
|
currencyStatus = CryptoCurrencyStatus(currency = mockk<CryptoCurrency>(relaxed = true), value = mockk(relaxed = true)),
|
||||||
|
providers = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun prevState(): SwapChooseTokenNetworkUM = SwapChooseTokenNetworkUM(
|
||||||
|
bottomSheetConfig = TangemBottomSheetConfig(
|
||||||
|
isShown = true,
|
||||||
|
onDismissRequest = {},
|
||||||
|
content = SwapChooseTokenNetworkContentUM.Loading(
|
||||||
|
messageContent = SwapChooseTokenFactory.getErrorMessage(tokenName = tokenName, onDismiss = {}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -30,7 +30,7 @@ features/swap/
|
||||||
|
|
||||||
| Symbol | Role | Path |
|
| 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` |
|
| `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` |
|
| `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` |
|
| `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` |
|
| `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` |
|
| `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
|
`SwapModel` state worth knowing: `dataStateStateFlow` (reactive domain data) and
|
||||||
`uiState: SwapStateHolder` (Compose state); the inner `FeeSelectorRepository` wires the
|
`uiState: SwapStateHolder` (Compose state); the inner `FeeSelectorRepository` wires the
|
||||||
send-v2 fee selector to `SwapInteractor.loadSwapFee`/`applySwapFee`.
|
send-v2 fee selector to `SwapInteractor.loadSwapFee`/`applySwapFee`.
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,11 @@ interface SwapComponent : ComposableContentComponent {
|
||||||
|
|
||||||
data class Params(
|
data class Params(
|
||||||
val userWalletId: UserWalletId,
|
val userWalletId: UserWalletId,
|
||||||
val cryptoCurrency: CryptoCurrency? = null,
|
val fromCryptoCurrency: CryptoCurrency? = null,
|
||||||
val screenSource: String,
|
val screenSource: String,
|
||||||
val currencyPosition: CurrencyPosition = CurrencyPosition.ANY,
|
val fromCurrencyPosition: CurrencyPosition = CurrencyPosition.ANY,
|
||||||
val tangemPayInput: TangemPayInput? = null,
|
val tangemPayInput: TangemPayInput? = null,
|
||||||
|
val toCryptoCurrency: CryptoCurrency? = null,
|
||||||
) {
|
) {
|
||||||
data class TangemPayInput(
|
data class TangemPayInput(
|
||||||
val cryptoAmount: BigDecimal,
|
val cryptoAmount: BigDecimal,
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,9 @@ internal class InitialCurrenciesResolver @Inject constructor(
|
||||||
* @param userWalletId the wallet to resolve currencies for
|
* @param userWalletId the wallet to resolve currencies for
|
||||||
* @param initialCryptoCurrency pre-selected currency, or null to auto-select
|
* @param initialCryptoCurrency pre-selected currency, or null to auto-select
|
||||||
* @param swapCurrencyPosition preferred position for the initial currency
|
* @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
|
* @return pair of (from, to) [SwapCurrencyStatus]; either or both may be null
|
||||||
*/
|
*/
|
||||||
suspend operator fun invoke(
|
suspend operator fun invoke(
|
||||||
|
|
@ -54,6 +57,7 @@ internal class InitialCurrenciesResolver @Inject constructor(
|
||||||
initialCryptoCurrency: CryptoCurrency?,
|
initialCryptoCurrency: CryptoCurrency?,
|
||||||
swapCurrencyPosition: CurrencyPosition,
|
swapCurrencyPosition: CurrencyPosition,
|
||||||
isPaymentAccount: Boolean,
|
isPaymentAccount: Boolean,
|
||||||
|
initialToCryptoCurrency: CryptoCurrency? = null,
|
||||||
): Pair<SwapCurrencyStatus?, SwapCurrencyStatus?> {
|
): Pair<SwapCurrencyStatus?, SwapCurrencyStatus?> {
|
||||||
val walletAccountList = getWalletAccountCurrencyStatusList(userWalletId)
|
val walletAccountList = getWalletAccountCurrencyStatusList(userWalletId)
|
||||||
val cryptoPortfolioAccounts = walletAccountList.filterKeys { accountStatus ->
|
val cryptoPortfolioAccounts = walletAccountList.filterKeys { accountStatus ->
|
||||||
|
|
@ -65,7 +69,7 @@ internal class InitialCurrenciesResolver @Inject constructor(
|
||||||
|
|
||||||
val cryptoCurrencyList = cryptoPortfolioAccounts.values.flatten()
|
val cryptoCurrencyList = cryptoPortfolioAccounts.values.flatten()
|
||||||
|
|
||||||
return if (initialCryptoCurrency != null) {
|
val (from, to) = if (initialCryptoCurrency != null) {
|
||||||
val selectedSwapCurrencyStatus = if (isPaymentAccount) {
|
val selectedSwapCurrencyStatus = if (isPaymentAccount) {
|
||||||
cryptoPaymentAccounts
|
cryptoPaymentAccounts
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -91,6 +95,43 @@ internal class InitialCurrenciesResolver @Inject constructor(
|
||||||
cryptoCurrencyList = cryptoCurrencyList,
|
cryptoCurrencyList = cryptoCurrencyList,
|
||||||
) to null
|
) to null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val resolvedTo = to ?: resolveExplicitToCurrency(
|
||||||
|
initialToCryptoCurrency = initialToCryptoCurrency,
|
||||||
|
from = from,
|
||||||
|
cryptoPortfolioAccountsMap = cryptoPortfolioAccounts,
|
||||||
|
)
|
||||||
|
|
||||||
|
return from to resolvedTo
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the optional explicit TO currency, but only if it is already present in the user's crypto
|
||||||
|
* portfolio. Matches by token identity ([isSameTokenAs]) rather than full id, since the passed currency
|
||||||
|
* may come from a different account/derivation. Prefers the instance from the FROM account, then falls
|
||||||
|
* back to the first match across the portfolio. Never returns the same token as FROM.
|
||||||
|
*/
|
||||||
|
private fun resolveExplicitToCurrency(
|
||||||
|
initialToCryptoCurrency: CryptoCurrency?,
|
||||||
|
from: SwapCurrencyStatus?,
|
||||||
|
cryptoPortfolioAccountsMap: Map<AccountStatus.CryptoPortfolio, List<SwapCurrencyStatus>>,
|
||||||
|
): SwapCurrencyStatus? {
|
||||||
|
if (initialToCryptoCurrency == null) return null
|
||||||
|
|
||||||
|
val fromCurrency = from?.currency
|
||||||
|
fun matches(status: SwapCurrencyStatus): Boolean {
|
||||||
|
return status.currency.isSameTokenAs(initialToCryptoCurrency) &&
|
||||||
|
(fromCurrency == null || !status.currency.isSameTokenAs(fromCurrency))
|
||||||
|
}
|
||||||
|
|
||||||
|
val fromAccountMatch = from?.account?.accountId?.let { fromAccountId ->
|
||||||
|
cryptoPortfolioAccountsMap.entries
|
||||||
|
.firstOrNull { (accountStatus, _) -> accountStatus.account.accountId == fromAccountId }
|
||||||
|
?.value
|
||||||
|
?.firstOrNull(::matches)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fromAccountMatch ?: cryptoPortfolioAccountsMap.values.flatten().firstOrNull(::matches)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -170,7 +170,7 @@ internal class SwapModel @Inject constructor(
|
||||||
|
|
||||||
private val params = paramsContainer.require<SwapComponent.Params>()
|
private val params = paramsContainer.require<SwapComponent.Params>()
|
||||||
|
|
||||||
private val initialCryptoCurrency = params.cryptoCurrency
|
private val initialCryptoCurrency = params.fromCryptoCurrency
|
||||||
private val tangemPayInput = params.tangemPayInput
|
private val tangemPayInput = params.tangemPayInput
|
||||||
|
|
||||||
private var isBalanceHidden = true
|
private var isBalanceHidden = true
|
||||||
|
|
@ -407,8 +407,9 @@ internal class SwapModel @Inject constructor(
|
||||||
val (fromSwapCurrencyStatus, toSwapCurrencyStatus) = initialCurrenciesResolver(
|
val (fromSwapCurrencyStatus, toSwapCurrencyStatus) = initialCurrenciesResolver(
|
||||||
userWalletId = params.userWalletId,
|
userWalletId = params.userWalletId,
|
||||||
initialCryptoCurrency = initialCryptoCurrency,
|
initialCryptoCurrency = initialCryptoCurrency,
|
||||||
swapCurrencyPosition = params.currencyPosition,
|
swapCurrencyPosition = params.fromCurrencyPosition,
|
||||||
isPaymentAccount = params.tangemPayInput != null,
|
isPaymentAccount = params.tangemPayInput != null,
|
||||||
|
initialToCryptoCurrency = params.toCryptoCurrency,
|
||||||
)
|
)
|
||||||
|
|
||||||
preselectedFromCurrency = fromSwapCurrencyStatus?.currency
|
preselectedFromCurrency = fromSwapCurrencyStatus?.currency
|
||||||
|
|
@ -2316,7 +2317,7 @@ internal class SwapModel @Inject constructor(
|
||||||
modelScope.launch {
|
modelScope.launch {
|
||||||
val transaction = dataState.getCurrentLoadedSwapState()?.swapDataModel?.transaction
|
val transaction = dataState.getCurrentLoadedSwapState()?.swapDataModel?.transaction
|
||||||
val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus
|
val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus
|
||||||
val fromCurrency = fromSwapCurrencyStatus?.currency ?: params.cryptoCurrency
|
val fromCurrency = fromSwapCurrencyStatus?.currency ?: params.fromCryptoCurrency
|
||||||
val fromWalletId = fromSwapCurrencyStatus?.userWalletId ?: params.userWalletId
|
val fromWalletId = fromSwapCurrencyStatus?.userWalletId ?: params.userWalletId
|
||||||
val network = fromCurrency?.network
|
val network = fromCurrency?.network
|
||||||
val fee = getSelectedSwapFee()?.fee
|
val fee = getSelectedSwapFee()?.fee
|
||||||
|
|
|
||||||
|
|
@ -1151,6 +1151,161 @@ internal class DefaultInitialCurrenciesResolverTest {
|
||||||
|
|
||||||
// endregion
|
// endregion
|
||||||
|
|
||||||
|
// region explicit TO currency
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN explicit TO currency present in portfolio WHEN invoke with position FROM THEN it is placed as TO`() =
|
||||||
|
runTest {
|
||||||
|
val fromId = mockk<CryptoCurrency.ID>(relaxed = true)
|
||||||
|
val initialFrom = mockCryptoCurrency(id = fromId)
|
||||||
|
val accountFrom = mockCryptoCurrency(id = fromId)
|
||||||
|
|
||||||
|
// Same token (network + contract), different id instance from the passed one.
|
||||||
|
val accountTo = mockCryptoCurrency(id = mockCurrencyId("ethereum", "0xUSDT"))
|
||||||
|
val explicitTo = mockCryptoCurrency(id = mockCurrencyId("ethereum", "0xUSDT"))
|
||||||
|
|
||||||
|
val fromStatus = createCurrencyStatus(accountFrom, fiatAmount = BigDecimal("100"))
|
||||||
|
val toStatus = createCurrencyStatus(accountTo, fiatAmount = BigDecimal("50"))
|
||||||
|
val accountStatus = createCryptoPortfolioAccountStatus(listOf(fromStatus, toStatus))
|
||||||
|
setupSupplier(listOf(accountStatus))
|
||||||
|
setupAvailability(linkedMapOf(accountFrom to true, accountTo to true))
|
||||||
|
|
||||||
|
val (from, to) = resolver.invoke(
|
||||||
|
userWalletId,
|
||||||
|
initialCryptoCurrency = initialFrom,
|
||||||
|
swapCurrencyPosition = CurrencyPosition.FROM,
|
||||||
|
isPaymentAccount = false,
|
||||||
|
initialToCryptoCurrency = explicitTo,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(from?.status).isSameInstanceAs(fromStatus)
|
||||||
|
assertThat(to?.status).isSameInstanceAs(toStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN explicit TO currency not present in portfolio WHEN invoke THEN TO stays null`() = runTest {
|
||||||
|
val fromId = mockk<CryptoCurrency.ID>(relaxed = true)
|
||||||
|
val initialFrom = mockCryptoCurrency(id = fromId)
|
||||||
|
val accountFrom = mockCryptoCurrency(id = fromId)
|
||||||
|
|
||||||
|
val explicitTo = mockCryptoCurrency(id = mockCurrencyId("ethereum", "0xNOT_IN_WALLET"))
|
||||||
|
|
||||||
|
val fromStatus = createCurrencyStatus(accountFrom, fiatAmount = BigDecimal("100"))
|
||||||
|
val accountStatus = createCryptoPortfolioAccountStatus(listOf(fromStatus))
|
||||||
|
setupSupplier(listOf(accountStatus))
|
||||||
|
setupAvailability(linkedMapOf(accountFrom to true))
|
||||||
|
|
||||||
|
val (from, to) = resolver.invoke(
|
||||||
|
userWalletId,
|
||||||
|
initialCryptoCurrency = initialFrom,
|
||||||
|
swapCurrencyPosition = CurrencyPosition.FROM,
|
||||||
|
isPaymentAccount = false,
|
||||||
|
initialToCryptoCurrency = explicitTo,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(from?.status).isSameInstanceAs(fromStatus)
|
||||||
|
assertThat(to).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN explicit TO currency is the same token as FROM WHEN invoke THEN TO stays null`() = runTest {
|
||||||
|
val sharedId = mockCurrencyId("ethereum", "0xUSDT")
|
||||||
|
val initialFrom = mockCryptoCurrency(id = sharedId)
|
||||||
|
val accountFrom = mockCryptoCurrency(id = sharedId)
|
||||||
|
// Passed TO is the same token as FROM (different id instance, same network + contract).
|
||||||
|
val explicitTo = mockCryptoCurrency(id = mockCurrencyId("ethereum", "0xUSDT"))
|
||||||
|
|
||||||
|
val fromStatus = createCurrencyStatus(accountFrom, fiatAmount = BigDecimal("100"))
|
||||||
|
val accountStatus = createCryptoPortfolioAccountStatus(listOf(fromStatus))
|
||||||
|
setupSupplier(listOf(accountStatus))
|
||||||
|
setupAvailability(linkedMapOf(accountFrom to true))
|
||||||
|
|
||||||
|
val (from, to) = resolver.invoke(
|
||||||
|
userWalletId,
|
||||||
|
initialCryptoCurrency = initialFrom,
|
||||||
|
swapCurrencyPosition = CurrencyPosition.FROM,
|
||||||
|
isPaymentAccount = false,
|
||||||
|
initialToCryptoCurrency = explicitTo,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(from?.status).isSameInstanceAs(fromStatus)
|
||||||
|
assertThat(to).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN TO slot already filled by position TO WHEN explicit TO provided THEN explicit TO is ignored`() =
|
||||||
|
runTest {
|
||||||
|
val sharedId = mockk<CryptoCurrency.ID>(relaxed = true)
|
||||||
|
val initialCurrency = mockCryptoCurrency(id = sharedId)
|
||||||
|
val accountCurrency = mockCryptoCurrency(id = sharedId)
|
||||||
|
|
||||||
|
// A different token that exists in the portfolio and would match the explicit TO.
|
||||||
|
val otherTo = mockCryptoCurrency(id = mockCurrencyId("ethereum", "0xUSDT"))
|
||||||
|
val explicitTo = mockCryptoCurrency(id = mockCurrencyId("ethereum", "0xUSDT"))
|
||||||
|
|
||||||
|
val status = createCurrencyStatus(accountCurrency, fiatAmount = BigDecimal("100"))
|
||||||
|
val otherStatus = createCurrencyStatus(otherTo, fiatAmount = BigDecimal("50"))
|
||||||
|
val accountStatus = createCryptoPortfolioAccountStatus(listOf(status, otherStatus))
|
||||||
|
setupSupplier(listOf(accountStatus))
|
||||||
|
setupAvailability(linkedMapOf(accountCurrency to true, otherTo to true))
|
||||||
|
|
||||||
|
val (from, to) = resolver.invoke(
|
||||||
|
userWalletId,
|
||||||
|
initialCryptoCurrency = initialCurrency,
|
||||||
|
swapCurrencyPosition = CurrencyPosition.TO,
|
||||||
|
isPaymentAccount = false,
|
||||||
|
initialToCryptoCurrency = explicitTo,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Position TO already placed the selected currency in TO; explicit TO must not override it.
|
||||||
|
assertThat(from).isNull()
|
||||||
|
assertThat(to?.status).isSameInstanceAs(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN explicit TO currency exists in multiple accounts WHEN invoke THEN instance from FROM account is used`() =
|
||||||
|
runTest {
|
||||||
|
// FROM lives in account 1.
|
||||||
|
val fromId = mockk<CryptoCurrency.ID>(relaxed = true)
|
||||||
|
val initialFrom = mockCryptoCurrency(id = fromId)
|
||||||
|
val accountFrom = mockCryptoCurrency(id = fromId)
|
||||||
|
|
||||||
|
// Same TO token present in both accounts (different id instances).
|
||||||
|
val toInAccount1 = mockCryptoCurrency(id = mockCurrencyId("ethereum", "0xUSDT"))
|
||||||
|
val toInAccount2 = mockCryptoCurrency(id = mockCurrencyId("ethereum", "0xUSDT"))
|
||||||
|
val explicitTo = mockCryptoCurrency(id = mockCurrencyId("ethereum", "0xUSDT"))
|
||||||
|
|
||||||
|
val fromStatus = createCurrencyStatus(accountFrom, fiatAmount = BigDecimal("100"))
|
||||||
|
val toInAccount1Status = createCurrencyStatus(toInAccount1, fiatAmount = BigDecimal("10"))
|
||||||
|
val toInAccount2Status = createCurrencyStatus(toInAccount2, fiatAmount = BigDecimal("5000"))
|
||||||
|
|
||||||
|
val account1 = createCryptoPortfolioAccountStatus(
|
||||||
|
currencies = listOf(fromStatus, toInAccount1Status),
|
||||||
|
derivationIndexValue = 0,
|
||||||
|
)
|
||||||
|
val account2 = createCryptoPortfolioAccountStatus(
|
||||||
|
currencies = listOf(toInAccount2Status),
|
||||||
|
derivationIndexValue = 1,
|
||||||
|
)
|
||||||
|
setupSupplier(listOf(account1, account2))
|
||||||
|
setupAvailability(linkedMapOf(accountFrom to true, toInAccount1 to true))
|
||||||
|
setupAvailability(linkedMapOf(toInAccount2 to true))
|
||||||
|
|
||||||
|
val (from, to) = resolver.invoke(
|
||||||
|
userWalletId,
|
||||||
|
initialCryptoCurrency = initialFrom,
|
||||||
|
swapCurrencyPosition = CurrencyPosition.FROM,
|
||||||
|
isPaymentAccount = false,
|
||||||
|
initialToCryptoCurrency = explicitTo,
|
||||||
|
)
|
||||||
|
|
||||||
|
// TO must be the instance from the FROM account, not the higher-balance duplicate in account 2.
|
||||||
|
assertThat(from?.status).isSameInstanceAs(fromStatus)
|
||||||
|
assertThat(to?.status).isSameInstanceAs(toInAccount1Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// endregion
|
||||||
|
|
||||||
// region helpers
|
// region helpers
|
||||||
|
|
||||||
private fun mockCryptoCurrency(
|
private fun mockCryptoCurrency(
|
||||||
|
|
|
||||||
|
|
@ -125,7 +125,7 @@ internal abstract class SwapModelTestBase {
|
||||||
|
|
||||||
protected fun createParams(): SwapComponent.Params = SwapComponent.Params(
|
protected fun createParams(): SwapComponent.Params = SwapComponent.Params(
|
||||||
userWalletId = userWalletId,
|
userWalletId = userWalletId,
|
||||||
cryptoCurrency = null,
|
fromCryptoCurrency = null,
|
||||||
screenSource = "Test",
|
screenSource = "Test",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -473,9 +473,9 @@ internal class TangemPayCardPageModel @Inject constructor(
|
||||||
bottomSheetNavigation.dismiss()
|
bottomSheetNavigation.dismiss()
|
||||||
router.push(
|
router.push(
|
||||||
AppRoute.Swap(
|
AppRoute.Swap(
|
||||||
cryptoCurrency = data.currency,
|
fromCryptoCurrency = data.currency,
|
||||||
userWalletId = data.walletId,
|
userWalletId = data.walletId,
|
||||||
currencyPosition = AppRoute.Swap.CurrencyPosition.TO,
|
fromCurrencyPosition = AppRoute.Swap.CurrencyPosition.TO,
|
||||||
screenSource = AnalyticsParam.ScreensSources.TangemPay.value,
|
screenSource = AnalyticsParam.ScreensSources.TangemPay.value,
|
||||||
tangemPayInput = AppRoute.Swap.TangemPayInput(
|
tangemPayInput = AppRoute.Swap.TangemPayInput(
|
||||||
cryptoAmount = data.cryptoBalance,
|
cryptoAmount = data.cryptoBalance,
|
||||||
|
|
|
||||||
|
|
@ -216,10 +216,10 @@ internal class TangemPayDetailsModel @Inject constructor(
|
||||||
}
|
}
|
||||||
router.push(
|
router.push(
|
||||||
AppRoute.Swap(
|
AppRoute.Swap(
|
||||||
cryptoCurrency = currency,
|
fromCryptoCurrency = currency,
|
||||||
userWalletId = userWalletId,
|
userWalletId = userWalletId,
|
||||||
screenSource = AnalyticsParam.ScreensSources.TangemPay.value,
|
screenSource = AnalyticsParam.ScreensSources.TangemPay.value,
|
||||||
currencyPosition = AppRoute.Swap.CurrencyPosition.FROM,
|
fromCurrencyPosition = AppRoute.Swap.CurrencyPosition.FROM,
|
||||||
tangemPayInput = AppRoute.Swap.TangemPayInput(
|
tangemPayInput = AppRoute.Swap.TangemPayInput(
|
||||||
cryptoAmount = balance.availableForWithdrawal,
|
cryptoAmount = balance.availableForWithdrawal,
|
||||||
fiatAmount = balance.availableForWithdrawal,
|
fiatAmount = balance.availableForWithdrawal,
|
||||||
|
|
@ -308,10 +308,10 @@ internal class TangemPayDetailsModel @Inject constructor(
|
||||||
bottomSheetNavigation.dismiss()
|
bottomSheetNavigation.dismiss()
|
||||||
router.push(
|
router.push(
|
||||||
AppRoute.Swap(
|
AppRoute.Swap(
|
||||||
cryptoCurrency = data.currency,
|
fromCryptoCurrency = data.currency,
|
||||||
userWalletId = data.walletId,
|
userWalletId = data.walletId,
|
||||||
screenSource = AnalyticsParam.ScreensSources.TangemPay.value,
|
screenSource = AnalyticsParam.ScreensSources.TangemPay.value,
|
||||||
currencyPosition = AppRoute.Swap.CurrencyPosition.TO,
|
fromCurrencyPosition = AppRoute.Swap.CurrencyPosition.TO,
|
||||||
tangemPayInput = AppRoute.Swap.TangemPayInput(
|
tangemPayInput = AppRoute.Swap.TangemPayInput(
|
||||||
cryptoAmount = data.cryptoBalance,
|
cryptoAmount = data.cryptoBalance,
|
||||||
fiatAmount = data.fiatBalance,
|
fiatAmount = data.fiatBalance,
|
||||||
|
|
|
||||||
|
|
@ -836,10 +836,10 @@ internal class TokenDetailsModel @Inject constructor(
|
||||||
} else {
|
} else {
|
||||||
appRouter.push(
|
appRouter.push(
|
||||||
AppRoute.Swap(
|
AppRoute.Swap(
|
||||||
cryptoCurrency = cryptoCurrency,
|
fromCryptoCurrency = cryptoCurrency,
|
||||||
userWalletId = userWalletId,
|
userWalletId = userWalletId,
|
||||||
screenSource = AnalyticsParam.ScreensSources.Token.value,
|
screenSource = AnalyticsParam.ScreensSources.Token.value,
|
||||||
currencyPosition = currencyPosition,
|
fromCurrencyPosition = currencyPosition,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -1322,7 +1322,7 @@ internal class TokenDetailsModel @Inject constructor(
|
||||||
TokenAction.Send -> sendCurrency()
|
TokenAction.Send -> sendCurrency()
|
||||||
TokenAction.Swap -> appRouter.push(
|
TokenAction.Swap -> appRouter.push(
|
||||||
AppRoute.Swap(
|
AppRoute.Swap(
|
||||||
cryptoCurrency = cryptoCurrency,
|
fromCryptoCurrency = cryptoCurrency,
|
||||||
userWalletId = userWalletId,
|
userWalletId = userWalletId,
|
||||||
screenSource = AnalyticsParam.ScreensSources.Token.value,
|
screenSource = AnalyticsParam.ScreensSources.Token.value,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -666,7 +666,7 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor(
|
||||||
private fun navigateToSwap(cryptoCurrencyStatus: CryptoCurrencyStatus, userWalletId: UserWalletId) {
|
private fun navigateToSwap(cryptoCurrencyStatus: CryptoCurrencyStatus, userWalletId: UserWalletId) {
|
||||||
appRouter.push(
|
appRouter.push(
|
||||||
AppRoute.Swap(
|
AppRoute.Swap(
|
||||||
cryptoCurrency = cryptoCurrencyStatus.currency,
|
fromCryptoCurrency = cryptoCurrencyStatus.currency,
|
||||||
userWalletId = userWalletId,
|
userWalletId = userWalletId,
|
||||||
screenSource = AnalyticsParam.ScreensSources.LongTap.value,
|
screenSource = AnalyticsParam.ScreensSources.LongTap.value,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue