Updated on 2026-08-14

This commit is contained in:
Tangem 2026-04-23 18:01:25 +05:00
parent 53b02cfb10
commit 2be7445212
81 changed files with 4444 additions and 3434 deletions

View file

@ -49,6 +49,18 @@ internal object SwapDomainModule {
)
}
@Provides
@Singleton
fun provideGetSwapPairUseCase(
swapRepositoryV2: SwapRepositoryV2,
swapErrorResolver: SwapErrorResolver,
): GetSwapPairUseCase {
return GetSwapPairUseCase(
swapRepositoryV2 = swapRepositoryV2,
swapErrorResolver = swapErrorResolver,
)
}
@Provides
@Singleton
fun provideSelectInitialPairUseCase(

View file

@ -85,11 +85,29 @@ internal class DefaultRampManager(
cryptoCurrency: CryptoCurrency,
): ScenarioUnavailabilityReason {
val availabilityState = runSuspendCatching {
getExchangeableState()
getExchangeableState(
userWalletId = userWalletId,
cryptoCurrency = cryptoCurrency,
)
}.getOrNull() ?: ExpressAvailabilityState.Error
return availabilityState.toReason(cryptoCurrency.name)
}
override suspend fun availableForSwap(
userWalletId: UserWalletId,
cryptoCurrencies: List<CryptoCurrency>,
): Map<CryptoCurrency, ScenarioUnavailabilityReason> {
val availabilityStates = runSuspendCatching {
getExchangeableStates(
userWalletId = userWalletId,
cryptoCurrencies = cryptoCurrencies,
)
}.getOrNull() ?: cryptoCurrencies.associateWith { ExpressAvailabilityState.Error }
return availabilityStates.mapValues { entry ->
entry.value.toReason(entry.key.name)
}
}
override fun getSellInitializationStatus(): Flow<SellServiceInitializationStatus> {
return sellService.initializationStatus
}
@ -141,9 +159,42 @@ internal class DefaultRampManager(
}
}
private fun getExchangeableState(): ExpressAvailabilityState {
// In task [REDACTED_TASK_KEY], removed all checks to make all tokens available
return ExpressAvailabilityState.Available
private suspend fun getExchangeableState(
userWalletId: UserWalletId,
cryptoCurrency: CryptoCurrency,
): ExpressAvailabilityState {
val asset = expressServiceFetcher.getInitializationStatus(userWalletId).firstOrNull()
?: return ExpressAvailabilityState.Loading
return when (asset) {
is Lce.Error -> ExpressAvailabilityState.Error
is Lce.Loading -> ExpressAvailabilityState.Loading
is Lce.Content -> {
val foundAsset = asset.getOrNull()?.find { cryptoCurrency.findAssetPredicate(assetId = it.id) }
foundAsset?.isExchangeAvailable?.toSwapAvailabilityState()
?: ExpressAvailabilityState.AssetNotFound
}
}
}
private suspend fun getExchangeableStates(
userWalletId: UserWalletId,
cryptoCurrencies: List<CryptoCurrency>,
): Map<CryptoCurrency, ExpressAvailabilityState> {
val asset = expressServiceFetcher.getInitializationStatus(userWalletId).firstOrNull()
?: return cryptoCurrencies.associateWith { ExpressAvailabilityState.Loading }
return when (asset) {
is Lce.Error -> cryptoCurrencies.associateWith { ExpressAvailabilityState.Error }
is Lce.Loading -> cryptoCurrencies.associateWith { ExpressAvailabilityState.Loading }
is Lce.Content -> {
val foundAsset = asset.getOrNull()
cryptoCurrencies.associateWith { cryptoCurrency ->
foundAsset?.find { cryptoCurrency.findAssetPredicate(assetId = it.id) }?.isExchangeAvailable
?.toSwapAvailabilityState() ?: ExpressAvailabilityState.AssetNotFound
}
}
}
}
private suspend fun getOnrampAvailableState(
@ -177,6 +228,14 @@ internal class DefaultRampManager(
}
}
private fun Boolean.toSwapAvailabilityState(): ExpressAvailabilityState {
return if (this) {
ExpressAvailabilityState.Available
} else {
ExpressAvailabilityState.NotExchangeable
}
}
private fun Boolean.toOnrampAvailabilityState(): ExpressAvailabilityState {
return if (this) {
ExpressAvailabilityState.Available

View file

@ -304,11 +304,14 @@ internal class ChildFactory @Inject constructor(
createComponentChild(
context = context,
params = SwapComponent.Params(
currencyFrom = route.currencyFrom,
currencyTo = route.currencyTo,
cryptoCurrency = route.cryptoCurrency,
userWalletId = route.userWalletId,
isInitialReverseOrder = route.isInitialReverseOrder,
screenSource = route.screenSource,
currencyPosition = when (route.currencyPosition) {
AppRoute.Swap.CurrencyPosition.FROM -> SwapComponent.Params.CurrencyPosition.FROM
AppRoute.Swap.CurrencyPosition.TO -> SwapComponent.Params.CurrencyPosition.TO
AppRoute.Swap.CurrencyPosition.ANY -> SwapComponent.Params.CurrencyPosition.ANY
},
tangemPayInput = route.tangemPayInput?.let { tangemPayInput ->
SwapComponent.Params.TangemPayInput(
cryptoAmount = tangemPayInput.cryptoAmount,

View file

@ -196,18 +196,15 @@ sealed class AppRoute(val path: String) : Route {
@Serializable
data class Swap(
val currencyFrom: CryptoCurrency,
val currencyTo: CryptoCurrency? = null,
val userWalletId: UserWalletId,
val isInitialReverseOrder: Boolean = false,
val cryptoCurrency: CryptoCurrency? = null,
val screenSource: String,
val currencyPosition: CurrencyPosition = CurrencyPosition.ANY,
val tangemPayInput: TangemPayInput? = null,
) : AppRoute(
path = "/swap" +
"/${currencyFrom.id.value}" +
"/${currencyTo?.id?.value}" +
"/${userWalletId.stringValue}" +
"/$isInitialReverseOrder",
"/${cryptoCurrency?.id?.value}" +
"/${userWalletId.stringValue}",
) {
@Serializable
data class TangemPayInput(
@ -216,6 +213,13 @@ sealed class AppRoute(val path: String) : Route {
val depositAddress: String,
val isWithdrawal: Boolean,
)
@Serializable
enum class CurrencyPosition {
FROM,
TO,
ANY,
}
}
@Serializable

View file

@ -118,9 +118,8 @@ class TokenActionsHandler @AssistedInject constructor(
private fun onExchangeClick(cryptoCurrencyData: CryptoCurrencyData) {
router.push(
AppRoute.Swap(
currencyFrom = cryptoCurrencyData.status.currency,
cryptoCurrency = cryptoCurrencyData.status.currency,
userWalletId = cryptoCurrencyData.userWallet.walletId,
isInitialReverseOrder = true,
screenSource = AnalyticsParam.ScreensSources.Markets.value,
),
)

View file

@ -11,7 +11,7 @@ object ExpressUtils {
private const val BATCH_ID_CHANGENOW = "BB000013"
private const val BATCH_ID_PARTNER = "AF990015"
fun getRefCode(userWallet: UserWallet, appPreferencesStore: AppPreferencesStore): String? {
fun getRefCode(userWallet: UserWallet?, appPreferencesStore: AppPreferencesStore): String? {
return when (userWallet) {
is UserWallet.Cold -> {
when {

View file

@ -11,6 +11,10 @@ android {
namespace = "com.tangem.data.swap"
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
/** Core */
implementation(projects.core.datasource)
@ -56,4 +60,10 @@ dependencies {
/** DI */
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
/** Test */
testRuntimeOnly(deps.test.junit5.engine)
testImplementation(tangemDeps.card.core)
testImplementation(projects.common.test)
testImplementation(projects.test.core)
}

View file

@ -13,6 +13,7 @@ import com.tangem.data.swap.converter.TokenInfoConverter
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.express.TangemExpressApi
import com.tangem.datasource.api.express.models.request.ExchangeSentRequestBody
import com.tangem.datasource.api.express.models.request.LeastTokenInfo
import com.tangem.datasource.api.express.models.request.PairsRequestBody
import com.tangem.datasource.api.express.models.response.ExchangeDataResponseWithTxDetails
import com.tangem.datasource.api.express.models.response.RateType
@ -34,7 +35,6 @@ import com.tangem.domain.quotes.single.SingleQuoteStatusProducer
import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier
import com.tangem.domain.swap.SwapRepositoryV2
import com.tangem.domain.swap.models.*
import com.tangem.domain.swap.models.SwapAmountType
import com.tangem.domain.tokens.operations.CryptoCurrencyStatusFactory
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger
@ -44,6 +44,7 @@ import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.withContext
import java.io.IOException
import java.math.BigDecimal
import java.math.RoundingMode
import java.util.UUID
import javax.inject.Inject
@ -64,6 +65,76 @@ internal class DefaultSwapRepositoryV2 @Inject constructor(
private val exchangeStatusConverter = SwapStatusConverter()
private val txDetailsMoshiAdapter = moshi.adapter(TxDetails::class.java)
override suspend fun getPairs(
primarySwapCurrencyStatus: SwapCurrencyStatus,
secondarySwapCurrencyStatus: SwapCurrencyStatus,
filterProviderTypes: List<ExpressProviderType>,
swapTxType: SwapTxType,
): List<SwapPairModel> = withContext(coroutineDispatcher.default) {
val primaryCurrency = primarySwapCurrencyStatus.currency
val secondaryCurrency = secondarySwapCurrencyStatus.currency
val primaryUserWallet = primarySwapCurrencyStatus.userWallet
val secondaryUserWallet = secondarySwapCurrencyStatus.userWallet
val pairs = awaitAll(
// original pairs
async {
invokePairRequest(
userWallet = primaryUserWallet,
from = listOf(primaryCurrency),
to = listOf(secondaryCurrency),
)
},
// reversed pairs
async {
invokePairRequest(
userWallet = secondaryUserWallet,
from = listOf(secondaryCurrency),
to = listOf(primaryCurrency),
)
},
).flatten()
val expressProviders = expressRepository.getFilteredProviders(
userWallet = primaryUserWallet,
filterProviderTypes = filterProviderTypes,
swapTxType = swapTxType,
).associateBy(ExpressProvider::providerId)
fun checkPair(currency: CryptoCurrency, pair: LeastTokenInfo): Boolean {
return currency.getContractAddress() == pair.contractAddress &&
currency.network.rawId == pair.network
}
pairs.mapNotNull { pair ->
val fromCurrencyStatus = when {
checkPair(primaryCurrency, pair.from) -> primarySwapCurrencyStatus.status
checkPair(secondaryCurrency, pair.from) -> secondarySwapCurrencyStatus.status
else -> null
}
val toCurrencyStatus = when {
checkPair(primaryCurrency, pair.to) -> primarySwapCurrencyStatus.status
checkPair(secondaryCurrency, pair.to) -> secondarySwapCurrencyStatus.status
else -> null
}
val mappedProviders = pair.providers
.mapNotNull { it.withExpressProvider(expressProviders) }
.filterYieldSupplyProvider(fromCurrencyStatus)
if (fromCurrencyStatus != null && toCurrencyStatus != null && mappedProviders.isNotEmpty()) {
SwapPairModel(
from = fromCurrencyStatus,
to = toCurrencyStatus,
providers = mappedProviders,
)
} else {
null
}
}
}
override suspend fun getPairs(
userWallet: UserWallet,
initialCurrency: CryptoCurrency,
@ -185,16 +256,12 @@ internal class DefaultSwapRepositoryV2 @Inject constructor(
): SwapQuoteModel = withContext(coroutineDispatcher.io) {
val response = tangemExpressApi.getExchangeQuote(
fromAmount = if (amountType == SwapAmountType.From) {
amount.movePointRight(
fromCryptoCurrency.decimals,
).toString()
amount.toStringWithRightOffset(fromCryptoCurrency.decimals)
} else {
null
},
toAmount = if (amountType == SwapAmountType.To) {
amount.movePointRight(
toCryptoCurrency.decimals,
).toString()
amount.toStringWithRightOffset(toCryptoCurrency.decimals)
} else {
null
},
@ -229,7 +296,7 @@ internal class DefaultSwapRepositoryV2 @Inject constructor(
userWallet: UserWallet,
fromCryptoCurrencyStatus: CryptoCurrencyStatus,
toCryptoCurrency: CryptoCurrency,
amount: String,
amount: BigDecimal,
amountType: SwapAmountType,
toAddress: String,
toExtraId: String?,
@ -261,8 +328,16 @@ internal class DefaultSwapRepositoryV2 @Inject constructor(
toAddress = toAddress,
fromDecimals = fromCurrency.decimals,
toDecimals = toCryptoCurrency.decimals,
fromAmount = if (amountType == SwapAmountType.From) amount else null,
toAmount = if (amountType == SwapAmountType.To) amount else null,
fromAmount = if (amountType == SwapAmountType.From) {
amount.toStringWithRightOffset(fromCurrency.decimals)
} else {
null
},
toAmount = if (amountType == SwapAmountType.To) {
amount.toStringWithRightOffset(toCryptoCurrency.decimals)
} else {
null
},
providerId = expressProvider.providerId,
rateType = rateType.name.lowercase(),
requestId = requestId,
@ -465,6 +540,10 @@ internal class DefaultSwapRepositoryV2 @Inject constructor(
}
}
private fun BigDecimal.toStringWithRightOffset(decimals: Int): String {
return setScale(decimals, RoundingMode.HALF_DOWN).movePointRight(decimals).toPlainString()
}
private fun List<ExpressProvider>.filterYieldSupplyProvider(cryptoCurrencyStatus: CryptoCurrencyStatus?) =
filter { provider ->
// !!!WARNING!!! Filter out dex provider if yield supply is active

View file

@ -54,7 +54,8 @@ internal class DefaultSwapTransactionRepository(
private val userTokensResponseFactory = UserTokensResponseFactory()
override suspend fun storeTransaction(
userWalletId: UserWalletId,
fromUserWalletId: UserWalletId,
toUserWalletId: UserWalletId,
fromCryptoCurrency: CryptoCurrency,
toCryptoCurrency: CryptoCurrency,
fromAccount: Account?,
@ -75,7 +76,10 @@ internal class DefaultSwapTransactionRepository(
val tokenTransactions = savedTransactions
?.firstOrNull { swapTxList ->
swapTxList.checkId(
checkUserWalletId = userWalletId,
fromUserWalletId = fromUserWalletId,
toUserWalletId = toUserWalletId,
fromAccountId = fromAccount?.accountId,
toAccountId = toAccount?.accountId,
fromCurrencyId = fromCryptoCurrency.id,
toCurrencyId = toCryptoCurrency.id,
)
@ -89,7 +93,8 @@ internal class DefaultSwapTransactionRepository(
mutablePreferences.setObject(
key = PreferencesKeys.SWAP_TRANSACTIONS_KEY,
value = savedTransactions?.updateList(
userWalletId = userWalletId,
fromUserWalletId = fromUserWalletId,
toUserWalletId = toUserWalletId,
fromCryptoCurrency = fromCryptoCurrency,
toCryptoCurrency = toCryptoCurrency,
fromAccount = fromAccount,
@ -97,7 +102,8 @@ internal class DefaultSwapTransactionRepository(
transactions = tokenTransactions,
) ?: listOf(
listConverter.default(
userWalletId = userWalletId,
fromUserWalletId = fromUserWalletId,
toUserWalletId = toUserWalletId,
fromCryptoCurrency = fromCryptoCurrency,
toCryptoCurrency = toCryptoCurrency,
fromAccount = fromAccount,
@ -123,7 +129,7 @@ internal class DefaultSwapTransactionRepository(
) { savedTransactions, txStatuses, multiAccountList ->
val currencyTxs = savedTransactions
?.filter { swapTxList ->
swapTxList.userWalletId == userWallet.walletId.stringValue &&
swapTxList.fromUserWalletId == userWallet.walletId.stringValue &&
(
swapTxList.toCryptoCurrencyId == cryptoCurrencyId.value ||
swapTxList.fromCryptoCurrencyId == cryptoCurrencyId.value
@ -222,19 +228,27 @@ internal class DefaultSwapTransactionRepository(
}
}
@Suppress("LongParameterList")
private fun SwapTransactionListDTO.checkId(
checkUserWalletId: UserWalletId,
fromUserWalletId: UserWalletId,
toUserWalletId: UserWalletId,
fromAccountId: AccountId?,
toAccountId: AccountId?,
fromCurrencyId: CryptoCurrency.ID,
toCurrencyId: CryptoCurrency.ID,
): Boolean {
return userWalletId == checkUserWalletId.stringValue &&
toCryptoCurrencyId == toCurrencyId.value &&
fromCryptoCurrencyId == fromCurrencyId.value
return this.fromUserWalletId == fromUserWalletId.stringValue &&
this.toUserWalletId == toUserWalletId.stringValue &&
this.fromTokensResponse?.accountId == fromAccountId?.value &&
this.toTokensResponse?.accountId == toAccountId?.value &&
fromCryptoCurrencyId == fromCurrencyId.value &&
toCryptoCurrencyId == toCurrencyId.value
}
@Suppress("LongParameterList")
private fun List<SwapTransactionListDTO>.updateList(
userWalletId: UserWalletId,
fromUserWalletId: UserWalletId,
toUserWalletId: UserWalletId,
fromCryptoCurrency: CryptoCurrency,
toCryptoCurrency: CryptoCurrency,
fromAccount: Account?,
@ -243,7 +257,8 @@ internal class DefaultSwapTransactionRepository(
): List<SwapTransactionListDTO> {
return addOrReplace(
item = listConverter.default(
userWalletId = userWalletId,
fromUserWalletId = fromUserWalletId,
toUserWalletId = toUserWalletId,
fromCryptoCurrency = fromCryptoCurrency,
toCryptoCurrency = toCryptoCurrency,
fromAccount = fromAccount,
@ -252,7 +267,10 @@ internal class DefaultSwapTransactionRepository(
),
predicate = { swapTxList ->
swapTxList.checkId(
checkUserWalletId = userWalletId,
fromUserWalletId = fromUserWalletId,
toUserWalletId = toUserWalletId,
fromAccountId = fromAccount?.accountId,
toAccountId = toAccount?.accountId,
fromCurrencyId = fromCryptoCurrency.id,
toCurrencyId = toCryptoCurrency.id,
)

View file

@ -31,7 +31,8 @@ internal class SavedSwapTransactionListConverter(
}
override fun convert(value: SwapTransactionListModel) = SwapTransactionListDTO(
userWalletId = value.userWalletId,
fromUserWalletId = value.fromUserWalletId,
toUserWalletId = value.toUserWalletId,
fromCryptoCurrencyId = value.fromCryptoCurrencyId,
toCryptoCurrencyId = value.toCryptoCurrencyId,
fromTokensResponse = userTokensResponseFactory.createResponseToken(
@ -79,7 +80,8 @@ internal class SavedSwapTransactionListConverter(
txStatuses = txStatuses,
)
},
userWalletId = value.userWalletId,
fromUserWalletId = value.fromUserWalletId,
toUserWalletId = value.toUserWalletId,
fromCryptoCurrencyId = value.fromCryptoCurrencyId,
toCryptoCurrencyId = value.toCryptoCurrencyId,
fromCryptoCurrency = fromCryptoCurrency,
@ -98,14 +100,16 @@ internal class SavedSwapTransactionListConverter(
@Suppress("LongParameterList")
fun default(
userWalletId: UserWalletId,
fromUserWalletId: UserWalletId,
toUserWalletId: UserWalletId,
fromCryptoCurrency: CryptoCurrency,
toCryptoCurrency: CryptoCurrency,
fromAccount: Account?,
toAccount: Account?,
tokenTransactions: List<SwapTransactionDTO>,
) = SwapTransactionListDTO(
userWalletId = userWalletId.stringValue,
fromUserWalletId = fromUserWalletId.stringValue,
toUserWalletId = toUserWalletId.stringValue,
fromCryptoCurrencyId = fromCryptoCurrency.id.value,
toCryptoCurrencyId = toCryptoCurrency.id.value,
fromTokensResponse = userTokensResponseFactory.createResponseToken(

View file

@ -9,7 +9,9 @@ import java.math.BigDecimal
@JsonClass(generateAdapter = true)
internal data class SwapTransactionListDTO(
@Json(name = "userWalletId")
val userWalletId: String,
val fromUserWalletId: String,
@Json(name = "toUserWalletId")
val toUserWalletId: String = fromUserWalletId,
@Json(name = "fromCryptoCurrencyId")
val fromCryptoCurrencyId: String,
@Json(name = "toCryptoCurrencyId")

View file

@ -0,0 +1,709 @@
package com.tangem.data.swap
import com.google.common.truth.Truth.assertThat
import com.squareup.moshi.Moshi
import com.tangem.common.test.domain.wallet.MockUserWalletFactory
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.express.TangemExpressApi
import com.tangem.datasource.api.express.models.request.LeastTokenInfo
import com.tangem.datasource.api.express.models.request.PairsRequestBody
import com.tangem.datasource.api.express.models.response.*
import com.tangem.datasource.crypto.DataSignatureVerifier
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.domain.express.ExpressRepository
import com.tangem.domain.express.models.*
import com.tangem.domain.express.models.ExpressError
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.models.yield.supply.YieldSupplyStatus
import com.tangem.domain.quotes.single.SingleQuoteStatusFetcher
import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier
import com.tangem.domain.swap.models.SwapAmountType
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.domain.swap.models.SwapStatus
import com.tangem.domain.swap.models.SwapTxType
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.*
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.api.assertThrows
import java.math.BigDecimal
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class DefaultSwapRepositoryV2Test {
private val tangemExpressApi: TangemExpressApi = mockk()
private val expressRepository: ExpressRepository = mockk()
private val appPreferencesStore: AppPreferencesStore = mockk(relaxed = true)
private val dataSignatureVerifier: DataSignatureVerifier = mockk()
private val singleQuoteStatusSupplier: SingleQuoteStatusSupplier = mockk()
private val singleQuoteStatusFetcher: SingleQuoteStatusFetcher = mockk()
private val moshi: Moshi = Moshi.Builder().build()
private val repository = DefaultSwapRepositoryV2(
tangemExpressApi = tangemExpressApi,
expressRepository = expressRepository,
coroutineDispatcher = TestingCoroutineDispatcherProvider(),
appPreferencesStore = appPreferencesStore,
dataSignatureVerifier = dataSignatureVerifier,
singleQuoteStatusSupplier = singleQuoteStatusSupplier,
singleQuoteStatusFetcher = singleQuoteStatusFetcher,
moshi = moshi,
)
@BeforeEach
fun resetMocks() {
clearMocks(
tangemExpressApi,
expressRepository,
appPreferencesStore,
dataSignatureVerifier,
singleQuoteStatusSupplier,
singleQuoteStatusFetcher,
)
}
// region getPairs(SwapCurrencyStatus, SwapCurrencyStatus)
@Test
fun `getPairs with SwapCurrencyStatus returns mapped pairs when providers match`() = runTest {
// Arrange
val primaryStatus = createCryptoCurrencyStatus(primaryCoin)
val secondaryStatus = createCryptoCurrencyStatus(secondaryCoin)
val primarySwapCurrencyStatus = SwapCurrencyStatus(
userWallet = userWallet,
status = primaryStatus,
account = mockk(),
)
val secondarySwapCurrencyStatus = SwapCurrencyStatus(
userWallet = userWallet,
status = secondaryStatus,
account = mockk(),
)
val swapPair = SwapPair(
from = LeastTokenInfo(contractAddress = "0", network = ETH_BACKEND_ID),
to = LeastTokenInfo(contractAddress = "0", network = BTC_BACKEND_ID),
providers = listOf(SwapPairProvider(providerId = PROVIDER_ID, rateTypes = listOf(RateType.FLOAT))),
)
coEvery {
tangemExpressApi.getPairs(any(), any(), any())
} returns ApiResponse.Success(listOf(swapPair))
coEvery {
expressRepository.getProviders(any(), any())
} returns listOf(expressProvider)
// Act
val result = repository.getPairs(
primarySwapCurrencyStatus = primarySwapCurrencyStatus,
secondarySwapCurrencyStatus = secondarySwapCurrencyStatus,
filterProviderTypes = emptyList(),
swapTxType = SwapTxType.Swap,
)
// Assert
assertThat(result).hasSize(2)
assertThat(result.first().from).isEqualTo(primaryStatus)
assertThat(result.first().to).isEqualTo(secondaryStatus)
assertThat(result.first().providers).hasSize(1)
assertThat(result.first().providers.first().providerId).isEqualTo(PROVIDER_ID)
}
@Test
fun `getPairs with SwapCurrencyStatus returns empty when no providers match`() = runTest {
// Arrange
val primaryStatus = createCryptoCurrencyStatus(primaryCoin)
val secondaryStatus = createCryptoCurrencyStatus(secondaryCoin)
val primarySwapCurrencyStatus = SwapCurrencyStatus(
userWallet = userWallet,
status = primaryStatus,
account = mockk(),
)
val secondarySwapCurrencyStatus = SwapCurrencyStatus(
userWallet = userWallet,
status = secondaryStatus,
account = mockk(),
)
val swapPair = SwapPair(
from = LeastTokenInfo(contractAddress = "0", network = ETH_BACKEND_ID),
to = LeastTokenInfo(contractAddress = "0", network = BTC_BACKEND_ID),
providers = listOf(SwapPairProvider(providerId = "unknown-provider", rateTypes = listOf(RateType.FLOAT))),
)
coEvery {
tangemExpressApi.getPairs(any(), any(), any())
} returns ApiResponse.Success(listOf(swapPair))
coEvery {
expressRepository.getProviders(any(), any())
} returns listOf(expressProvider)
// Act
val result = repository.getPairs(
primarySwapCurrencyStatus = primarySwapCurrencyStatus,
secondarySwapCurrencyStatus = secondarySwapCurrencyStatus,
filterProviderTypes = emptyList(),
swapTxType = SwapTxType.Swap,
)
// Assert
assertThat(result).isEmpty()
}
@Test
fun `getPairs with SwapCurrencyStatus returns empty when API returns empty pairs`() = runTest {
// Arrange
val primarySwapCurrencyStatus = SwapCurrencyStatus(
userWallet = userWallet,
status = createCryptoCurrencyStatus(primaryCoin),
account = mockk(),
)
val secondarySwapCurrencyStatus = SwapCurrencyStatus(
userWallet = userWallet,
status = createCryptoCurrencyStatus(secondaryCoin),
account = mockk(),
)
coEvery {
tangemExpressApi.getPairs(any(), any(), any())
} returns ApiResponse.Success(emptyList())
coEvery {
expressRepository.getProviders(any(), any())
} returns listOf(expressProvider)
// Act
val result = repository.getPairs(
primarySwapCurrencyStatus = primarySwapCurrencyStatus,
secondarySwapCurrencyStatus = secondarySwapCurrencyStatus,
filterProviderTypes = emptyList(),
swapTxType = SwapTxType.Swap,
)
// Assert
assertThat(result).isEmpty()
}
// endregion
// region getPairs(UserWallet, CryptoCurrency, List<CryptoCurrencyStatus>)
@Test
fun `getPairs with currency status list returns mapped pairs`() = runTest {
// Arrange
val primaryStatus = createCryptoCurrencyStatus(primaryCoin)
val secondaryStatus = createCryptoCurrencyStatus(secondaryCoin)
val swapPair = SwapPair(
from = LeastTokenInfo(contractAddress = "0", network = ETH_BACKEND_ID),
to = LeastTokenInfo(contractAddress = "0", network = BTC_BACKEND_ID),
providers = listOf(SwapPairProvider(providerId = PROVIDER_ID, rateTypes = listOf(RateType.FLOAT))),
)
coEvery {
tangemExpressApi.getPairs(any(), any(), any())
} returns ApiResponse.Success(listOf(swapPair))
coEvery {
expressRepository.getProviders(any(), any())
} returns listOf(expressProvider)
// Act
val result = repository.getPairs(
userWallet = userWallet,
initialCurrency = primaryCoin,
cryptoCurrencyStatusList = listOf(primaryStatus, secondaryStatus),
filterProviderTypes = emptyList(),
swapTxType = SwapTxType.Swap,
)
// Assert
assertThat(result).hasSize(2)
assertThat(result.first().from).isEqualTo(primaryStatus)
assertThat(result.first().to).isEqualTo(secondaryStatus)
}
@Test
fun `getPairs with SendWithSwap only fetches forward pairs`() = runTest {
// Arrange
val primaryStatus = createCryptoCurrencyStatus(primaryCoin)
val secondaryStatus = createCryptoCurrencyStatus(secondaryCoin)
val swapPair = SwapPair(
from = LeastTokenInfo(contractAddress = "0", network = ETH_BACKEND_ID),
to = LeastTokenInfo(contractAddress = "0", network = BTC_BACKEND_ID),
providers = listOf(SwapPairProvider(providerId = PROVIDER_ID, rateTypes = listOf(RateType.FLOAT))),
)
coEvery {
tangemExpressApi.getPairs(any(), any(), any())
} returns ApiResponse.Success(listOf(swapPair))
coEvery {
expressRepository.getProviders(any(), any())
} returns listOf(expressProvider)
// Act
val result = repository.getPairs(
userWallet = userWallet,
initialCurrency = primaryCoin,
cryptoCurrencyStatusList = listOf(primaryStatus, secondaryStatus),
filterProviderTypes = emptyList(),
swapTxType = SwapTxType.SendWithSwap,
)
// Assert
assertThat(result).hasSize(1)
// SendWithSwap should call getPairs only once (forward), not twice (forward + reverse)
coVerify(exactly = 1) { tangemExpressApi.getPairs(any(), any(), any()) }
}
// endregion
// region getSwapQuote
@Test
fun `getSwapQuote returns correct quote model for fromAmount`() = runTest {
// Arrange
val quoteResponse = ExchangeQuoteResponse(
fromAmount = "1000000000000000000",
fromDecimals = 18,
toAmount = "100000000",
toDecimals = 8,
allowanceContract = "0xAllowance",
minAmount = BigDecimal.ONE,
quoteId = "quote-123",
)
coEvery {
tangemExpressApi.getExchangeQuote(
fromAmount = any(),
toAmount = any(),
fromNetwork = any(),
fromContractAddress = any(),
fromDecimals = any(),
toNetwork = any(),
toContractAddress = any(),
toDecimals = any(),
providerId = any(),
rateType = any(),
userWalletId = any(),
refCode = any(),
)
} returns ApiResponse.Success(quoteResponse)
// Act
val result = repository.getSwapQuote(
userWallet = userWallet,
fromCryptoCurrency = primaryCoin,
toCryptoCurrency = secondaryCoin,
amount = BigDecimal.ONE,
amountType = SwapAmountType.From,
provider = expressProvider,
rateType = ExpressRateType.Float,
)
// Assert
assertThat(result.provider).isEqualTo(expressProvider)
assertThat(result.toTokenAmount).isEqualTo(BigDecimal("1.00000000"))
assertThat(result.fromTokenAmount).isEqualTo(BigDecimal("1.000000000000000000"))
assertThat(result.allowanceContract).isEqualTo("0xAllowance")
assertThat(result.quoteId).isEqualTo("quote-123")
}
@Test
fun `getSwapQuote sends fromAmount when amountType is From`() = runTest {
// Arrange
val quoteResponse = ExchangeQuoteResponse(
fromAmount = "1000000000000000000",
fromDecimals = 18,
toAmount = "100000000",
toDecimals = 8,
allowanceContract = null,
minAmount = BigDecimal.ONE,
quoteId = null,
)
coEvery {
tangemExpressApi.getExchangeQuote(
fromAmount = any(),
toAmount = any(),
fromNetwork = any(),
fromContractAddress = any(),
fromDecimals = any(),
toNetwork = any(),
toContractAddress = any(),
toDecimals = any(),
providerId = any(),
rateType = any(),
userWalletId = any(),
refCode = any(),
)
} returns ApiResponse.Success(quoteResponse)
// Act
repository.getSwapQuote(
userWallet = userWallet,
fromCryptoCurrency = primaryCoin,
toCryptoCurrency = secondaryCoin,
amount = BigDecimal("2.5"),
amountType = SwapAmountType.From,
provider = expressProvider,
rateType = ExpressRateType.Float,
)
// Assert — fromAmount is set, toAmount is null
coVerify {
tangemExpressApi.getExchangeQuote(
fromAmount = "2500000000000000000",
toAmount = null,
fromNetwork = ETH_BACKEND_ID,
fromContractAddress = "0",
fromDecimals = 18,
toNetwork = BTC_BACKEND_ID,
toContractAddress = "0",
toDecimals = 8,
providerId = PROVIDER_ID,
rateType = "float",
userWalletId = any(),
refCode = any(),
)
}
}
@Test
fun `getSwapQuote sends toAmount when amountType is To`() = runTest {
// Arrange
val quoteResponse = ExchangeQuoteResponse(
fromAmount = "1000000000000000000",
fromDecimals = 18,
toAmount = "100000000",
toDecimals = 8,
allowanceContract = null,
minAmount = BigDecimal.ONE,
quoteId = null,
)
coEvery {
tangemExpressApi.getExchangeQuote(
fromAmount = any(),
toAmount = any(),
fromNetwork = any(),
fromContractAddress = any(),
fromDecimals = any(),
toNetwork = any(),
toContractAddress = any(),
toDecimals = any(),
providerId = any(),
rateType = any(),
userWalletId = any(),
refCode = any(),
)
} returns ApiResponse.Success(quoteResponse)
// Act
repository.getSwapQuote(
userWallet = userWallet,
fromCryptoCurrency = primaryCoin,
toCryptoCurrency = secondaryCoin,
amount = BigDecimal("1.5"),
amountType = SwapAmountType.To,
provider = expressProvider,
rateType = ExpressRateType.Fixed,
)
// Assert — toAmount is set, fromAmount is null
coVerify {
tangemExpressApi.getExchangeQuote(
fromAmount = null,
toAmount = "150000000",
fromNetwork = ETH_BACKEND_ID,
fromContractAddress = "0",
fromDecimals = 18,
toNetwork = BTC_BACKEND_ID,
toContractAddress = "0",
toDecimals = 8,
providerId = PROVIDER_ID,
rateType = "fixed",
userWalletId = any(),
refCode = any(),
)
}
}
// endregion
// region getExchangeStatus
@Test
fun `getExchangeStatus returns converted status model`() = runTest {
// Arrange
val statusResponse = ExchangeStatusResponse(
providerId = PROVIDER_ID,
status = ExchangeStatus.Finished,
externalTxId = "ext-tx-1",
externalTxUrl = "https://example.com/tx/1",
error = null,
)
coEvery {
tangemExpressApi.getExchangeStatus(any(), any(), any())
} returns ApiResponse.Success(statusResponse)
// Act
val result = repository.getExchangeStatus(userWallet = userWallet, txId = "tx-123")
// Assert
assertThat(result.providerId).isEqualTo(PROVIDER_ID)
assertThat(result.status).isEqualTo(SwapStatus.Finished)
assertThat(result.txId).isEqualTo("ext-tx-1")
assertThat(result.txExternalUrl).isEqualTo("https://example.com/tx/1")
}
// endregion
// region swapTransactionSent
@Test
fun `swapTransactionSent calls exchangeSent API`() = runTest {
// Arrange
val fromStatus = createCryptoCurrencyStatus(primaryCoin)
coEvery {
tangemExpressApi.exchangeSent(any(), any(), any())
} returns ApiResponse.Success(ExchangeSentResponseBody(txId = "tx-1", status = "ok"))
// Act
repository.swapTransactionSent(
userWallet = userWallet,
fromCryptoCurrencyStatus = fromStatus,
payInAddress = "0xPayIn",
txId = "tx-1",
txHash = "0xHash",
txExtraId = null,
)
// Assert
coVerify {
tangemExpressApi.exchangeSent(
userWalletId = any(),
refCode = any(),
body = match { body ->
body.txId == "tx-1" &&
body.txHash == "0xHash" &&
body.payinAddress == "0xPayIn" &&
body.payinExtraId == null
},
)
}
}
// endregion
// region filterYieldSupplyProvider
@Test
fun `getPairs filters out DEX providers when yield supply is active`() = runTest {
// Arrange
val primaryStatus = createCryptoCurrencyStatusWithActiveYield(primaryCoin)
val secondaryStatus = createCryptoCurrencyStatus(secondaryCoin)
val primarySwapCurrencyStatus = SwapCurrencyStatus(
userWallet = userWallet,
status = primaryStatus,
account = mockk(),
)
val secondarySwapCurrencyStatus = SwapCurrencyStatus(
userWallet = userWallet,
status = secondaryStatus,
account = mockk(),
)
val swapPair = SwapPair(
from = LeastTokenInfo(contractAddress = "0", network = ETH_BACKEND_ID),
to = LeastTokenInfo(contractAddress = "0", network = BTC_BACKEND_ID),
providers = listOf(
SwapPairProvider(providerId = PROVIDER_ID, rateTypes = listOf(RateType.FLOAT)),
SwapPairProvider(providerId = CEX_PROVIDER_ID, rateTypes = listOf(RateType.FLOAT)),
),
)
coEvery {
tangemExpressApi.getPairs(any(), any(), any())
} returns ApiResponse.Success(listOf(swapPair))
coEvery {
expressRepository.getProviders(any(), any())
} returns listOf(dexProvider, cexProvider)
// Act
val result = repository.getPairs(
primarySwapCurrencyStatus = primarySwapCurrencyStatus,
secondarySwapCurrencyStatus = secondarySwapCurrencyStatus,
filterProviderTypes = emptyList(),
swapTxType = SwapTxType.Swap,
)
// Assert — only CEX provider should remain
assertThat(result).hasSize(2)
val providers = result.first().providers
assertThat(providers).hasSize(1)
assertThat(providers.first().type).isEqualTo(ExpressProviderType.CEX)
}
// endregion
// region getSwapData
@Test
fun `getSwapData throws InvalidSignatureError when signature verification fails`() = runTest {
// Arrange
val fromStatus = createCryptoCurrencyStatus(primaryCoin)
val exchangeDataResponse = ExchangeDataResponse(
fromAmount = "1000000000000000000",
fromDecimals = 18,
toAmount = "100000000",
toDecimals = 8,
txId = "tx-123",
txDetailsJson = "{}",
signature = "invalid-sig",
)
coEvery {
tangemExpressApi.getExchangeData(
fromContractAddress = any(),
toContractAddress = any(),
fromNetwork = any(),
toNetwork = any(),
fromAddress = any(),
toAddress = any(),
fromDecimals = any(),
toDecimals = any(),
fromAmount = any(),
toAmount = any(),
providerId = any(),
rateType = any(),
requestId = any(),
refundAddress = any(),
refundExtraId = any(),
userWalletId = any(),
partnerOperationType = any(),
refCode = any(),
toExtraId = any(),
quoteId = any(),
)
} returns ApiResponse.Success(exchangeDataResponse)
every { dataSignatureVerifier.verifySignature(any(), any()) } returns false
// Act & Assert
assertThrows<ExpressError.InvalidSignatureError> {
repository.getSwapData(
userWallet = userWallet,
fromCryptoCurrencyStatus = fromStatus,
toCryptoCurrency = secondaryCoin,
amount = BigDecimal.ONE,
amountType = SwapAmountType.From,
toAddress = "0xToAddress",
toExtraId = null,
expressProvider = cexProvider,
rateType = ExpressRateType.Float,
expressOperationType = ExpressOperationType.SWAP,
quoteId = null,
)
}
}
// endregion
private companion object {
const val ETH_BACKEND_ID = "ethereum"
const val BTC_BACKEND_ID = "bitcoin"
const val PROVIDER_ID = "dex-provider-1"
const val CEX_PROVIDER_ID = "cex-provider-1"
val userWallet: UserWallet = MockUserWalletFactory.create()
val ethNetwork: Network = mockk(relaxed = true) {
every { rawId } returns ETH_BACKEND_ID
}
val btcNetwork: Network = mockk(relaxed = true) {
every { rawId } returns BTC_BACKEND_ID
}
val primaryCoin: CryptoCurrency.Coin = mockk(relaxed = true) {
every { network } returns ethNetwork
every { decimals } returns 18
every { id } returns mockk(relaxed = true) {
every { rawCurrencyId } returns CryptoCurrency.RawID("ethereum")
}
}
val secondaryCoin: CryptoCurrency.Coin = mockk(relaxed = true) {
every { network } returns btcNetwork
every { decimals } returns 8
every { id } returns mockk(relaxed = true) {
every { rawCurrencyId } returns CryptoCurrency.RawID("bitcoin")
}
}
val expressProvider = ExpressProvider(
providerId = PROVIDER_ID,
rateTypes = listOf(ExpressRateType.Float),
name = "Test DEX",
type = ExpressProviderType.DEX,
imageLarge = "",
termsOfUse = null,
privacyPolicy = null,
slippage = null,
)
val dexProvider = expressProvider
val cexProvider = ExpressProvider(
providerId = CEX_PROVIDER_ID,
rateTypes = listOf(ExpressRateType.Float),
name = "Test CEX",
type = ExpressProviderType.CEX,
imageLarge = "",
termsOfUse = null,
privacyPolicy = null,
slippage = null,
)
fun createCryptoCurrencyStatus(currency: CryptoCurrency): CryptoCurrencyStatus {
val value: CryptoCurrencyStatus.Value = mockk(relaxed = true) {
every { yieldSupplyStatus } returns null
every { networkAddress } returns mockk(relaxed = true) {
every { defaultAddress } returns mockk(relaxed = true) {
every { value } returns "0xAddress"
}
}
}
return CryptoCurrencyStatus(currency = currency, value = value)
}
fun createCryptoCurrencyStatusWithActiveYield(currency: CryptoCurrency): CryptoCurrencyStatus {
val yieldStatus: YieldSupplyStatus = mockk {
every { isActive } returns true
}
val value: CryptoCurrencyStatus.Value = mockk(relaxed = true) {
every { yieldSupplyStatus } returns yieldStatus
every { networkAddress } returns mockk(relaxed = true) {
every { defaultAddress } returns mockk(relaxed = true) {
every { value } returns "0xAddress"
}
}
}
return CryptoCurrencyStatus(currency = currency, value = value)
}
}
}

View file

@ -2,6 +2,7 @@ package com.tangem.domain.express.models
import java.math.BigDecimal
@Suppress("MagicNumber")
sealed class ExpressError : Throwable() {
abstract val code: Int
@ -61,4 +62,12 @@ sealed class ExpressError : Throwable() {
data object UnknownError : ExpressError() {
override val code: Int = -1
}
data class TooLargeSolanaTransactionError(override val code: Int = -2) : ExpressError() {
override val message: String = "tooLargeSolanaTransaction"
}
data class DexActiveSupplyError(override val code: Int = -3) : ExpressError() {
override val message: String = "dexActiveSupplyError"
}
}

View file

@ -23,8 +23,7 @@ enum class ExpressProviderType(val typeName: String) {
ONRAMP(typeName = "ONRAMP"),
;
companion object {
fun ExpressProviderType.shouldStoreSwapTransaction() = when (this) {
fun shouldStoreSwapTransaction() = when (this) {
CEX,
DEX_BRIDGE,
DEX,
@ -32,5 +31,10 @@ enum class ExpressProviderType(val typeName: String) {
ONRAMP,
-> false
}
companion object {
fun getSwapProviderTypes(): List<ExpressProviderType> {
return listOf(CEX, DEX, DEX_BRIDGE)
}
}
}

View file

@ -31,11 +31,22 @@ interface RampStateManager {
sendUnavailabilityReason: ScenarioUnavailabilityReason?,
): Either<ScenarioUnavailabilityReason, Unit>
/**
* Check if [CryptoCurrency] is available for swap (express/assets request)
*
* @param userWalletId the ID of the user's wallet
* @param cryptoCurrency cryptocurrency
*/
suspend fun availableForSwap(
userWalletId: UserWalletId,
cryptoCurrency: CryptoCurrency,
): ScenarioUnavailabilityReason
suspend fun availableForSwap(
userWalletId: UserWalletId,
cryptoCurrencies: List<CryptoCurrency>,
): Map<CryptoCurrency, ScenarioUnavailabilityReason>
suspend fun fetchSellServiceData()
fun getSellInitializationStatus(): Flow<Lce<Throwable, Any>>

View file

@ -0,0 +1,33 @@
package com.tangem.domain.swap.models
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
/**
* Represents the status of a cryptocurrency in the context of a swap operation.
*
* Combines the [UserWallet], [CryptoCurrencyStatus], and [Account] to provide
* all necessary information about a currency participating in a swap.
*
* @property userWallet the user wallet that owns the currency
* @property status the current status of the cryptocurrency, including balance and value state
* @property account the account within the wallet that holds the currency
* @property currency shortcut to the [CryptoCurrency] from [status]
* @property userWalletId shortcut to the wallet ID from [userWallet]
* @property isAvailableForSwap whether this currency can participate in a swap operation,
* determined by [RampStateManager][com.tangem.domain.exchange.RampStateManager]
*/
data class SwapCurrencyStatus(
val userWallet: UserWallet,
val status: CryptoCurrencyStatus,
val account: Account,
val isAvailableForSwap: Boolean = true,
) {
val currency: CryptoCurrency
get() = status.currency
val userWalletId: UserWalletId
get() = userWallet.walletId
}

View file

@ -9,7 +9,8 @@ import java.math.BigDecimal
* List of saved swap transactions
*/
data class SwapTransactionListModel(
val userWalletId: String,
val fromUserWalletId: String,
val toUserWalletId: String,
val fromCryptoCurrencyId: String,
val toCryptoCurrencyId: String,
val fromCryptoCurrency: CryptoCurrency,

View file

@ -16,6 +16,21 @@ import java.math.BigDecimal
@Suppress("LongParameterList")
interface SwapRepositoryV2 {
/**
* Returns express swap pairs for a specific primary and secondary currency.
*
* @param primarySwapCurrencyStatus primary currency status participating in the swap
* @param secondarySwapCurrencyStatus secondary currency status participating in the swap
* @param filterProviderTypes filters only specified provider types, if empty returns providers as is
* @param swapTxType swap tx type
*/
suspend fun getPairs(
primarySwapCurrencyStatus: SwapCurrencyStatus,
secondarySwapCurrencyStatus: SwapCurrencyStatus,
filterProviderTypes: List<ExpressProviderType>,
swapTxType: SwapTxType,
): List<SwapPairModel>
/**
* Express swap pairs, both direct and reversed
*
@ -84,7 +99,7 @@ interface SwapRepositoryV2 {
userWallet: UserWallet,
fromCryptoCurrencyStatus: CryptoCurrencyStatus,
toCryptoCurrency: CryptoCurrency,
amount: String,
amount: BigDecimal,
amountType: SwapAmountType,
toAddress: String,
toExtraId: String?,

View file

@ -18,7 +18,8 @@ interface SwapTransactionRepository {
/**
* Store new swap transaction
*
* @param userWalletId selected user wallet id
* @param fromUserWalletId wallet id swap from
* @param toUserWalletId wallet id swap to
* @param fromCryptoCurrency currency swap from
* @param toCryptoCurrency currency swap to
* @param fromAccount account swap from
@ -27,7 +28,8 @@ interface SwapTransactionRepository {
*/
@Suppress("LongParameterList")
suspend fun storeTransaction(
userWalletId: UserWalletId,
fromUserWalletId: UserWalletId,
toUserWalletId: UserWalletId,
fromCryptoCurrency: CryptoCurrency,
toCryptoCurrency: CryptoCurrency,
fromAccount: Account?,

View file

@ -12,6 +12,7 @@ import com.tangem.domain.swap.SwapErrorResolver
import com.tangem.domain.swap.SwapRepositoryV2
import com.tangem.domain.swap.models.SwapAmountType
import com.tangem.domain.swap.models.SwapDataModel
import java.math.BigDecimal
@Suppress("LongParameterList")
class GetSwapDataUseCase(
@ -22,7 +23,7 @@ class GetSwapDataUseCase(
suspend operator fun invoke(
userWallet: UserWallet,
fromCryptoCurrencyStatus: CryptoCurrencyStatus,
amount: String,
amount: BigDecimal,
amountType: SwapAmountType,
toCryptoCurrency: CryptoCurrency,
toAddress: String,

View file

@ -0,0 +1,35 @@
package com.tangem.domain.swap.usecase
import arrow.core.Either
import com.tangem.domain.express.models.ExpressProviderType
import com.tangem.domain.swap.SwapErrorResolver
import com.tangem.domain.swap.SwapRepositoryV2
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.domain.swap.models.SwapTxType
/**
* Use case for retrieving swap pairs between a specific primary and secondary currency.
*
* Returns either a list of available swap pairs or a resolved swap error.
*
* @property swapRepositoryV2 repository providing swap pair data
* @property swapErrorResolver resolver that maps exceptions to domain swap errors
*/
class GetSwapPairUseCase(
private val swapRepositoryV2: SwapRepositoryV2,
private val swapErrorResolver: SwapErrorResolver,
) {
suspend operator fun invoke(
primarySwapCurrencyStatus: SwapCurrencyStatus,
secondarySwapCurrencyStatus: SwapCurrencyStatus,
filterProviderTypes: List<ExpressProviderType>,
swapTxType: SwapTxType,
) = Either.catch {
swapRepositoryV2.getPairs(
primarySwapCurrencyStatus = primarySwapCurrencyStatus,
secondarySwapCurrencyStatus = secondarySwapCurrencyStatus,
filterProviderTypes = filterProviderTypes,
swapTxType = swapTxType,
)
}.mapLeft(swapErrorResolver::resolve)
}

View file

@ -2,7 +2,6 @@ package com.tangem.domain.swap.usecase
import arrow.core.Either
import com.tangem.domain.express.models.ExpressProvider
import com.tangem.domain.express.models.ExpressProviderType.Companion.shouldStoreSwapTransaction
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
@ -19,7 +18,8 @@ class SwapTransactionSentUseCase(
) {
suspend operator fun invoke(
userWallet: UserWallet,
fromUserWallet: UserWallet,
toUserWallet: UserWallet,
fromCryptoCurrencyStatus: CryptoCurrencyStatus,
toCryptoCurrencyStatus: CryptoCurrencyStatus,
fromAccount: Account?,
@ -33,7 +33,8 @@ class SwapTransactionSentUseCase(
) = Either.catch {
if (provider.type.shouldStoreSwapTransaction()) {
swapTransactionRepository.storeTransaction(
userWalletId = userWallet.walletId,
fromUserWalletId = fromUserWallet.walletId,
toUserWalletId = toUserWallet.walletId,
fromCryptoCurrency = fromCryptoCurrencyStatus.currency,
toCryptoCurrency = toCryptoCurrencyStatus.currency,
fromAccount = fromAccount,
@ -58,11 +59,11 @@ class SwapTransactionSentUseCase(
}
swapTransactionRepository.storeLastSwappedCryptoCurrencyId(
userWalletId = userWallet.walletId,
userWalletId = fromUserWallet.walletId,
cryptoCurrencyId = toCryptoCurrencyStatus.currency.id,
)
swapRepositoryV2.swapTransactionSent(
userWallet = userWallet,
userWallet = fromUserWallet,
fromCryptoCurrencyStatus = fromCryptoCurrencyStatus,
payInAddress = payInAddress,
txId = swapDataTransactionModel.txId,

View file

@ -3,14 +3,11 @@ package com.tangem.domain.tokens.actions
import com.tangem.domain.exchange.RampStateManager
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.staking.model.StakingAvailability
import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason
import com.tangem.domain.tokens.model.TokenActionsState.ActionState
import com.tangem.domain.transaction.models.AssetRequirementsCondition
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.yield.supply.models.YieldSupplyAvailability
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
@ -65,20 +62,6 @@ internal class CommonActionsFactory(
getSendUnavailabilityReason(userWalletId = userWallet.walletId, cryptoCurrencyStatus = cryptoCurrencyStatus)
}
val swapUnavailabilityReason = if (!cryptoCurrencyStatus.currency.isCustom &&
cryptoCurrencyStatus.value !is CryptoCurrencyStatus.NoQuote
) {
async {
getSwapUnavailabilityReason(
userWalletId = userWallet.walletId,
currencyStatus = cryptoCurrencyStatus,
requirementsDeferred = requirementsDeferred,
)
}
} else {
null
}
val hideTokenUnavailabilityReason = getTokenHideUnavailabilityReason(userWallet)
actionAvailabilityBuilder {
@ -111,7 +94,6 @@ internal class CommonActionsFactory(
createSwapAction(
userWallet = userWallet,
cryptoCurrencyStatus = cryptoCurrencyStatus,
swapUnavailableReasonDeferred = swapUnavailabilityReason,
shouldShowSwapStories = shouldShowSwapStories,
).addByReason()
// endregion
@ -140,11 +122,9 @@ internal class CommonActionsFactory(
}
}
@Suppress("CanBeNonNullable")
private suspend fun createSwapAction(
private fun createSwapAction(
userWallet: UserWallet,
cryptoCurrencyStatus: CryptoCurrencyStatus,
swapUnavailableReasonDeferred: Deferred<ScenarioUnavailabilityReason>?,
shouldShowSwapStories: Boolean,
): ActionState {
val cryptoCurrency = cryptoCurrencyStatus.currency
@ -172,35 +152,11 @@ internal class CommonActionsFactory(
)
}
else -> {
val reason = requireNotNull(swapUnavailableReasonDeferred) {
"swapUnavailableReasonDeferred must not be null for available swap action"
}.await()
return ActionState.Swap(
unavailabilityReason = reason,
shouldShowBadge = reason == ScenarioUnavailabilityReason.None && shouldShowSwapStories,
ActionState.Swap(
unavailabilityReason = ScenarioUnavailabilityReason.None,
shouldShowBadge = shouldShowSwapStories,
)
}
}
}
private suspend fun getSwapUnavailabilityReason(
userWalletId: UserWalletId,
currencyStatus: CryptoCurrencyStatus,
requirementsDeferred: Deferred<AssetRequirementsCondition?>?,
): ScenarioUnavailabilityReason {
val swapUnavailabilityReason = rampStateManager
.availableForSwap(userWalletId = userWalletId, cryptoCurrency = currencyStatus.currency)
val shouldCheckAssetRequirements =
swapUnavailabilityReason == ScenarioUnavailabilityReason.None && requirementsDeferred != null
val yieldSupplyStatus = currencyStatus.value.yieldSupplyStatus
val isUnavailableByYieldSupply = yieldSupplyStatus?.isAllowedToSpend == false && yieldSupplyStatus.isActive
return when {
isUnavailableByYieldSupply -> ScenarioUnavailabilityReason.YieldSupplyApprovalRequired
shouldCheckAssetRequirements -> getReceiveScenario(requirementsDeferred.await())
else -> swapUnavailabilityReason
}
}
}

View file

@ -9,13 +9,32 @@ import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.mapNotNull
class GetPaymentAccountCryptoCurrencyStatusUseCase(
private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier,
) {
suspend operator fun invoke(
operator fun invoke(
userWalletId: UserWalletId,
cryptoCurrency: CryptoCurrency,
): Flow<Pair<Account.Payment, CryptoCurrencyStatus>> {
return paymentAccountStatusSupplier(userWalletId).mapNotNull { accountStatus ->
val cryptoCurrencyStatus = when (val statusValue = accountStatus.value) {
is PaymentAccountStatusValue.Loaded -> statusValue.cryptoCurrencyStatus
else -> return@mapNotNull null
}
if (cryptoCurrencyStatus.currency == cryptoCurrency) {
accountStatus.account to cryptoCurrencyStatus
} else {
null
}
}
}
suspend fun invokeSync(
userWalletId: UserWalletId,
cryptoCurrency: CryptoCurrency,
): Option<Pair<Account.Payment, CryptoCurrencyStatus>> {

View file

@ -2,10 +2,11 @@ package com.tangem.features.onramp.deeplink
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import com.tangem.utils.logging.TangemLogger
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import com.tangem.utils.logging.TangemLogger
internal class DefaultSwapDeepLinkHandler @AssistedInject constructor(
router: AppRouter,
@ -19,7 +20,12 @@ internal class DefaultSwapDeepLinkHandler @AssistedInject constructor(
TangemLogger.e("Error on getting user wallet: $it")
},
ifRight = { userWallet ->
router.push(AppRoute.SwapCrypto(userWallet.walletId))
router.push(
AppRoute.Swap(
userWalletId = userWallet.walletId,
screenSource = AnalyticsParam.ScreensSources.Main.value,
),
)
},
)
}

View file

@ -114,8 +114,7 @@ internal class SwapSelectTokensModel @Inject constructor(
router.push(
route = AppRoute.Swap(
currencyFrom = requireNotNull(fromCurrencyStatus.value).currency,
currencyTo = status.currency,
cryptoCurrency = requireNotNull(fromCurrencyStatus.value).currency,
userWalletId = params.userWalletId,
screenSource = AnalyticsParam.ScreensSources.Main.value,
),

View file

@ -309,10 +309,7 @@ internal class OnrampTokenListModel @Inject constructor(
).isRight()
}
OnrampOperation.SWAP -> {
val isAvailable = rampStateManager.availableForSwap(
userWalletId = params.userWalletId,
cryptoCurrency = status.currency,
).isAvailable() && !status.currency.isCustom
val isAvailable = !status.currency.isCustom
val supplyStatus = status.value.yieldSupplyStatus
val isUnavailableByYieldSupply = supplyStatus?.isAllowedToSpend == false && supplyStatus.isActive

View file

@ -20,13 +20,11 @@ import com.tangem.core.ui.extensions.WrappedList
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.exchange.RampStateManager
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.domain.tokens.GetMinimumTransactionAmountSyncUseCase
import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.send.v2.api.entity.PredefinedValues
@ -44,7 +42,6 @@ import com.tangem.utils.extensions.orZero
import com.tangem.utils.isNullOrZero
import com.tangem.utils.transformer.update
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
import kotlin.properties.Delegates
@ -61,7 +58,6 @@ internal class SendAmountModel @Inject constructor(
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val feeSelectorReloadTrigger: FeeSelectorReloadTrigger,
private val getUserWalletUseCase: GetUserWalletUseCase,
private val rampStateManager: RampStateManager,
private val sendAmountAlertFactory: SendAmountAlertFactory,
private val getWalletsUseCase: GetWalletsUseCase,
) : Model(), SendAmountClickIntents {
@ -73,7 +69,6 @@ internal class SendAmountModel @Inject constructor(
private val _uiState = MutableStateFlow(params.state)
val uiState = _uiState.asStateFlow()
private var isAvailableForSwap: Boolean = false
val isSendWithSwapAvailable: StateFlow<Boolean>
field = MutableStateFlow(false)
@ -87,12 +82,6 @@ internal class SendAmountModel @Inject constructor(
private var maxAmountBoundary: EnterAmountBoundary by Delegates.notNull()
init {
modelScope.launch {
isAvailableForSwap = rampStateManager.availableForSwap(
userWalletId = params.userWalletId,
cryptoCurrency = params.cryptoCurrency,
) == ScenarioUnavailabilityReason.None
}
configAmountNavigation()
initAppCurrency()
subscribeOnCryptoCurrencyStatusFlow()
@ -409,7 +398,7 @@ internal class SendAmountModel @Inject constructor(
val isMultiCurrency = userWallet?.isMultiCurrency == true
isSendWithSwapAvailable.update {
isAvailableForSwap && isMultiCurrency && !params.predefinedValues.isFromMainScreenQr
!params.cryptoCurrency.isCustom && isMultiCurrency && !params.predefinedValues.isFromMainScreenQr
}
}
}

View file

@ -26,12 +26,11 @@ import com.tangem.domain.utils.convertToSdkAmount
import com.tangem.features.send.v2.api.subcomponents.feeSelector.utils.FeeCalculationUtils
import com.tangem.features.swap.v2.impl.common.ConfirmData
import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM
import com.tangem.utils.logging.TangemLogger
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import com.tangem.utils.logging.TangemLogger
import java.math.BigDecimal
import java.math.RoundingMode
@Suppress("LongParameterList")
internal class SwapTransactionSender @AssistedInject constructor(
@ -91,7 +90,7 @@ internal class SwapTransactionSender @AssistedInject constructor(
val feeValue = confirmData.fee?.amount?.value ?: return
val destination = confirmData.enteredDestination ?: return
val (swapDataRequestAmount, swapDataRequestCurrency) = when (confirmData.amountType) {
val swapDataRequestAmount = when (confirmData.amountType) {
SwapAmountType.From -> {
val amountValue = confirmData.enteredFromAmount ?: return
val subtracted = FeeCalculationUtils.checkAndCalculateSubtractedAmount(
@ -101,11 +100,11 @@ internal class SwapTransactionSender @AssistedInject constructor(
feeValue = feeValue,
reduceAmountBy = confirmData.reduceAmountBy,
)
subtracted to fromStatus
subtracted
}
SwapAmountType.To -> {
val amountValue = confirmData.enteredToAmount ?: return
amountValue to toStatus
amountValue
}
}
@ -117,7 +116,7 @@ internal class SwapTransactionSender @AssistedInject constructor(
val swapData = getSwapDataUseCase(
userWallet = userWallet,
fromCryptoCurrencyStatus = fromStatus,
amount = swapDataRequestAmount.toStringWithRightOffset(swapDataRequestCurrency.currency.decimals),
amount = swapDataRequestAmount,
amountType = confirmData.amountType,
toCryptoCurrency = toStatus.currency,
toAddress = destination,
@ -208,7 +207,8 @@ internal class SwapTransactionSender @AssistedInject constructor(
ifRight = { txHash ->
val timestamp = System.currentTimeMillis()
swapTransactionSentUseCase.invoke(
userWallet = userWallet,
fromUserWallet = userWallet,
toUserWallet = userWallet,
fromCryptoCurrencyStatus = fromStatus,
toCryptoCurrencyStatus = toStatus,
fromAccount = fromAccount,
@ -225,10 +225,6 @@ internal class SwapTransactionSender @AssistedInject constructor(
)
}
private fun BigDecimal.toStringWithRightOffset(decimals: Int): String {
return setScale(decimals, RoundingMode.HALF_DOWN).movePointRight(decimals).toPlainString()
}
@AssistedFactory
interface Factory {
fun create(userWallet: UserWallet): SwapTransactionSender

148
features/swap/CLAUDE.md Normal file
View file

@ -0,0 +1,148 @@
# Swap Feature
Token-to-token exchange feature. Users select FROM and TO tokens, get quotes from providers (DEX/CEX), approve ERC-20 allowances if needed, and execute swaps.
## Module Structure
```
features/swap/
api/ — Public contracts (SwapComponent, SwapEntryComponent, SwapFeatureToggles)
impl/ — UI, model, navigation, DI, token selection subfeature
domain/ — Business logic (SwapInteractor) + domain models
api/ — Domain interfaces
models/ — Domain model types (SwapPair, SwapProvider, SwapState, etc.)
data/ — Repository implementations, Retrofit APIs, Moshi DTOs
```
**Package naming:** API = `com.tangem.features.swap`, Impl = `com.tangem.feature.swap` (singular `feature`, legacy inconsistency).
## Key Components
### SwapComponent (API)
Entry point. `Params` requires `currencyFrom`, `userWalletId`, `screenSource`. Optional: `currencyTo`, `isInitialReverseOrder`, `tangemPayInput`, `preselectedToToken`, `preselectedAccount`.
### SwapEntryComponent (API)
Gateway component with sealed `Params`: `Story`, `Empty`, `Selected`, `Payment`. Routes to stories or directly to swap based on input type. See `entry/SwapEntryRoute.kt` for route definitions.
### DefaultSwapComponent (impl)
Decompose component. Creates `SwapModel` via `getOrCreateModel(params)`.
**Child navigation:**
- `childStack(SwapRoute)` for screen navigation — `SwapRoute.Main`, `SwapRoute.Success`, `SwapRoute.SelectToken(isFromDirection)` rendered via `Children` composable with fade animation
- `SlotNavigation<Unit>` for approval bottom sheet (`GiveApprovalComponent`)
- `SlotNavigation<FeeSelectorConfig>` for fee selector block
**Injected factories:** `SwapFeeSelectorBlockComponent.Factory`, `GiveApprovalComponent.Factory`, `ChooseTokenComponent.Factory`.
### SwapModel (impl)
`@ModelScoped`, extends `Model()`. The central coordinator — ~1500 lines.
**Key state:**
- `dataStateStateFlow: MutableStateFlow<SwapProcessDataState>` — reactive domain data (from/to tokens, pairs, providers, amounts, fees)
- `uiState: SwapStateHolder by mutableStateOf()` — Compose UI state built by `StateBuilder`
- `feeSelectorRepository: FeeSelectorRepository` — fee state management
- `stackNavigation: StackNavigation<SwapRoute>` — stack navigation exposed from `SwapRouter`
- `approvalSlotNavigation: SlotNavigation<Unit>` — approval bottom sheet
**Navigation:**
- `SwapRouter` wraps `AppRouter` + `StackNavigation<SwapRoute>` for screen switching and back navigation
- `swapRouter.openScreen(SwapRoute.SelectToken(isFromDirection))` to push token selection
- `swapRouter.openScreen(SwapRoute.Success)` replaces current with success screen
- `swapRouter.back()` — pops local stack or exits swap via AppRouter
**Initialization flow (init block):**
1. Subscribes to `chooseTokenBridge.onCurrencyChosen``onTokenSelect(result)`
2. Subscribes to `chooseTokenBridge.onClose` → pops slot navigation
3. Checks `ShouldShowStoriesUseCase` → pushes `AppRoute.Stories` if first-time swap
4. Resolves user country for FCA restrictions
5. Loads primary account status, initial currencies, and starts swap pair loading
**Token selection flow:**
1. User taps FROM or TO card → `onSelectTokenClick(direction)` pushes `SwapRoute.SelectToken(isFromDirection)` to stack
2. Stack creates `ChooseTokenComponent` with appropriate bridge (FROM or TO)
3. `ChooseTokenBridge` communicates selection result via Channel
4. `onTokenSelect(result)` assigns selected token to FROM or TO based on `isFromDirection`
**Swap execution flow:**
1. `onSwapClick()` — validates state, checks approval, initiates transaction
2. If approval needed → `approvalSlotNavigation.activate(Unit)`
3. On approval done → reloads quotes
4. On swap success → `swapRouter.openScreen(SwapRoute.Success)`
### StateBuilder (impl)
Pure transformation class. Takes `UiActions` + providers, builds `SwapStateHolder` from `SwapProcessDataState`.
Key methods: `createInitialLoadingState`, `createQuotesLoadedState`, `createSuccessState`, `loadingPermissionState`, `updateSwapAmount`, `addNotification`, `dismissBottomSheet`.
### SwapRouter (impl)
Wraps `AppRouter` + `StackNavigation<SwapRoute>`. Handles `openScreen(SwapRoute)` to push/replace stack entries and `back()` with special logic: SelectToken pops local stack, Success exits to screen before SwapCrypto in app stack, Main pops AppRouter. `openTokenDetails()` navigates to `AppRoute.CurrencyDetails`.
## Token Selection Subfeature (impl)
Self-contained within `choosetoken/` package:
- `ChooseTokenComponent` — API with `Params(bridge, settings, analyticsPayload)`
- `ChooseTokenBridge` — Channel-based communication: `onCurrencyChosen`, `onClose`, `onTokenSelected` (legacy), `onNewTokenAdded` (legacy). Has `settingsStateFlow` for dynamic settings.
- `ChooseTokenComponent.Settings``SwapFrom` (no market block) vs `SwapTo` (with market block)
- `ChooseTokenResult` — Contains `CryptoCurrencyStatus`, `AccountStatus`, `UserWallet`
- `DefaultChooseTokenComponent` — Has its own `ChooseTokenModel` and optional `AddToPortfolioComponent` bottom sheet slot
## Domain Layer
### SwapInteractor
Central domain interface. Methods:
- `getPair(from, to, filterProviderTypes)``Either<ExpressError, List<SwapPairLeast>>`
- `findBestQuote(from, to, providers, amount, ...)``Map<SwapProvider, SwapState>`
- `onSwap(from, to, provider, swapData, amount, fee, ...)``SwapTransactionState`
- `loadFeeForSwapTransaction(...)``Either<GetFeeError, TransactionFee/TransactionFeeExtended>`
- `getInitialCurrencyToSwap(accountStatusList, fromUserWallet, isReverse)``AccountCryptoCurrencyStatus?`
- `getTokenBalance(token)``SwapAmount`
### Key Domain Models
- `SwapPairLeast` — from/to token info + providers list
- `SwapProvider` — providerId, name, type (DEX/CEX/DEX_BRIDGE), rates, slippage, TOS links
- `SwapState` — sealed: `QuotesLoadedState`, `SwapError`, `EmptyAmountState`
- `SwapCurrencyStatus` — wraps `CryptoCurrencyStatus` + `UserWallet` + `Account`
- `SwapAmount` — value + decimals pair
- `SwapDataModel` — quote result with transaction data
## DI Modules
| Module | Scope | Bindings |
|--------|-------|----------|
| `SwapFeatureModule` | Singleton | `SwapComponent.Factory`, `SwapFeatureToggles` |
| `SwapModelModule` | ModelComponent | `SwapModel` into model map |
| `SwapEntryModule` | Singleton + Model | `SwapEntryComponent.Factory`, `SwapEntryModel` |
| `ChooseTokenModule` | Singleton + Model | `ChooseTokenComponent.Factory`, `ChooseTokenBridge.Factory`, `ChooseTokenModel` |
| `SwapSingletonModule` | Singleton | `AmountFormatter` |
## UI Layer
- `SwapScreen` — main swap composable (send card, receive card, swap button, provider, notifications, fee)
- `SwapSuccessScreen` — post-swap success with transaction details
- `SwapScreenContent` — layout with `ConstraintLayout` for card positioning
- `TransactionCard` / `TransactionCardEmpty` — token cards with amount input
- Token cards pass `TokenSelectionDirection.FROM` / `.TO` to `onSelectTokenClick`
## Navigation Summary
```
AppRouter (global)
└─ AppRoute.Swap → DefaultSwapComponent
├─ childStack(SwapRoute)
│ ├─ SwapRoute.Main → SwapMainChild (renders SwapScreen)
│ ├─ SwapRoute.Success → SwapSuccessChild (renders SwapSuccessScreen)
│ └─ SwapRoute.SelectToken → ChooseTokenComponent (FROM or TO bridge)
├─ SlotNavigation<Unit> (Approval)
│ └─ GiveApprovalComponent (bottom sheet)
└─ SlotNavigation<FeeSelectorConfig>
└─ SwapFeeSelectorBlockComponent (inline fee block)
```
## Build Commands
```bash
./gradlew :features:swap:impl:compileDebugKotlin
./gradlew :features:swap:api:compileDebugKotlin
./gradlew :features:swap:domain:compileDebugKotlin
./gradlew :features:swap:impl:detekt
```

View file

@ -9,11 +9,10 @@ import java.math.BigDecimal
interface SwapComponent : ComposableContentComponent {
data class Params(
val currencyFrom: CryptoCurrency,
val currencyTo: CryptoCurrency? = null,
val userWalletId: UserWalletId,
val isInitialReverseOrder: Boolean = false,
val cryptoCurrency: CryptoCurrency? = null,
val screenSource: String,
val currencyPosition: CurrencyPosition = CurrencyPosition.ANY,
val tangemPayInput: TangemPayInput? = null,
) {
data class TangemPayInput(
@ -22,6 +21,16 @@ interface SwapComponent : ComposableContentComponent {
val depositAddress: String,
val isWithdrawal: Boolean,
)
/** Preferred position of the pre-selected currency on the swap screen. */
enum class CurrencyPosition {
/** Force-place as the FROM (send) currency. */
FROM,
/** Force-place as the TO (receive) currency. */
TO,
/** Auto-determine position based on availability and balance. */
ANY,
}
}
interface Factory : ComponentFactory<Params, SwapComponent>

View file

@ -23,6 +23,7 @@ import com.tangem.domain.exchange.RampStateManager
import com.tangem.domain.express.models.ExpressOperationType
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.feature.swap.converters.*
import com.tangem.feature.swap.domain.api.SwapRepository
@ -226,7 +227,8 @@ internal class DefaultSwapRepository(
}
override suspend fun getExchangeStatus(
userWallet: UserWallet,
userWallet: UserWallet?,
userWalletId: UserWalletId,
txId: String,
): Either<UnknownError, ExchangeStatusModel> {
return withContext(coroutineDispatcher.io) {
@ -236,7 +238,7 @@ internal class DefaultSwapRepository(
exchangeStatusConverter.convert(
tangemExpressApi
.getExchangeStatus(
userWalletId = userWallet.walletId.stringValue,
userWalletId = userWalletId.stringValue,
refCode = ExpressUtils.getRefCode(
userWallet = userWallet,
appPreferencesStore = appPreferencesStore,

View file

@ -41,7 +41,8 @@ internal class DefaultSwapTransactionRepository(
private val userTokensResponseFactory = UserTokensResponseFactory()
override suspend fun storeTransaction(
userWalletId: UserWalletId,
fromUserWalletId: UserWalletId,
toUserWalletId: UserWalletId,
fromCryptoCurrency: CryptoCurrency,
toCryptoCurrency: CryptoCurrency,
fromAccount: Account?,
@ -62,7 +63,8 @@ internal class DefaultSwapTransactionRepository(
val tokenTransactions = savedTransactions
?.firstOrNull { savedTx ->
savedTx.checkId(
checkUserWalletId = userWalletId,
fromUserWalletId = fromUserWalletId,
toUserWalletId = toUserWalletId,
fromCurrencyId = fromCryptoCurrency.id,
toCurrencyId = toCryptoCurrency.id,
)
@ -76,7 +78,8 @@ internal class DefaultSwapTransactionRepository(
mutablePreferences.setObject(
key = PreferencesKeys.SWAP_TRANSACTIONS_KEY,
value = savedTransactions?.updateList(
userWalletId = userWalletId,
fromUserWalletId = fromUserWalletId,
toUserWalletId = toUserWalletId,
fromCryptoCurrency = fromCryptoCurrency,
toCryptoCurrency = toCryptoCurrency,
fromAccount = fromAccount,
@ -84,7 +87,8 @@ internal class DefaultSwapTransactionRepository(
transactions = tokenTransactions,
) ?: listOf(
converter.default(
userWalletId = userWalletId,
fromUserWalletId = fromUserWalletId,
toUserWalletId = toUserWalletId,
fromCryptoCurrency = fromCryptoCurrency,
toCryptoCurrency = toCryptoCurrency,
fromAccount = fromAccount,
@ -111,13 +115,13 @@ internal class DefaultSwapTransactionRepository(
) { savedTransactions, txStatuses, accountList ->
val currencyToTxs = savedTransactions?.filter { savedTx ->
val isUserWallet = savedTx.userWalletId == userWallet.walletId.stringValue
val isUserWallet = savedTx.toUserWalletId == userWallet.walletId.stringValue
val isToCurrency = savedTx.toCryptoCurrencyId == cryptoCurrencyId.value
isUserWallet && isToCurrency
}
val currencyFromTxs = savedTransactions?.filter { savedTx ->
val isUserWallet = savedTx.userWalletId == userWallet.walletId.stringValue
val isUserWallet = savedTx.fromUserWalletId == userWallet.walletId.stringValue
val isFromCurrency = savedTx.fromCryptoCurrencyId == cryptoCurrencyId.value
isUserWallet && isFromCurrency
}
@ -227,18 +231,21 @@ internal class DefaultSwapTransactionRepository(
}
private fun SavedSwapTransactionListModelInner.checkId(
checkUserWalletId: UserWalletId,
fromUserWalletId: UserWalletId,
toUserWalletId: UserWalletId,
fromCurrencyId: CryptoCurrency.ID,
toCurrencyId: CryptoCurrency.ID,
): Boolean {
return userWalletId == checkUserWalletId.stringValue &&
return this.fromUserWalletId == fromUserWalletId.stringValue &&
this.toUserWalletId == toUserWalletId.stringValue &&
toCryptoCurrencyId == toCurrencyId.value &&
fromCryptoCurrencyId == fromCurrencyId.value
}
@Suppress("LongParameterList")
private fun List<SavedSwapTransactionListModelInner>.updateList(
userWalletId: UserWalletId,
fromUserWalletId: UserWalletId,
toUserWalletId: UserWalletId,
fromCryptoCurrency: CryptoCurrency,
toCryptoCurrency: CryptoCurrency,
fromAccount: Account?,
@ -247,7 +254,8 @@ internal class DefaultSwapTransactionRepository(
): List<SavedSwapTransactionListModelInner> {
return addOrReplace(
item = converter.default(
userWalletId = userWalletId,
fromUserWalletId = fromUserWalletId,
toUserWalletId = toUserWalletId,
fromCryptoCurrency = fromCryptoCurrency,
toCryptoCurrency = toCryptoCurrency,
fromAccount = fromAccount,
@ -256,7 +264,8 @@ internal class DefaultSwapTransactionRepository(
),
predicate = { savedTx ->
savedTx.checkId(
checkUserWalletId = userWalletId,
fromUserWalletId = fromUserWalletId,
toUserWalletId = toUserWalletId,
fromCurrencyId = fromCryptoCurrency.id,
toCurrencyId = toCryptoCurrency.id,
)

View file

@ -28,7 +28,8 @@ internal class SavedSwapTransactionListConverter(
private val userTokensResponseFactory = UserTokensResponseFactory()
override fun convert(value: SavedSwapTransactionListModel) = SavedSwapTransactionListModelInner(
userWalletId = value.userWalletId,
fromUserWalletId = value.fromUserWalletId,
toUserWalletId = value.toUserWalletId,
fromCryptoCurrencyId = value.fromCryptoCurrencyId,
toCryptoCurrencyId = value.toCryptoCurrencyId,
fromTokensResponse = userTokensResponseFactory.createResponseToken(
@ -98,7 +99,8 @@ internal class SavedSwapTransactionListConverter(
val statusWithRefundCurrency = status?.copy(refundCurrency = refundCurrency)
tx.copy(status = statusWithRefundCurrency)
},
userWalletId = value.userWalletId,
fromUserWalletId = value.fromUserWalletId,
toUserWalletId = value.toUserWalletId,
fromCryptoCurrencyId = value.fromCryptoCurrencyId,
toCryptoCurrencyId = value.toCryptoCurrencyId,
fromCryptoCurrency = fromCryptoCurrency,
@ -117,14 +119,16 @@ internal class SavedSwapTransactionListConverter(
@Suppress("LongParameterList")
fun default(
userWalletId: UserWalletId,
fromUserWalletId: UserWalletId,
toUserWalletId: UserWalletId,
fromCryptoCurrency: CryptoCurrency,
toCryptoCurrency: CryptoCurrency,
fromAccount: Account?,
toAccount: Account?,
tokenTransactions: List<SavedSwapTransactionModel>,
) = SavedSwapTransactionListModelInner(
userWalletId = userWalletId.stringValue,
fromUserWalletId = fromUserWalletId.stringValue,
toUserWalletId = toUserWalletId.stringValue,
fromCryptoCurrencyId = fromCryptoCurrency.id.value,
toCryptoCurrencyId = toCryptoCurrency.id.value,
fromTokensResponse = userTokensResponseFactory.createResponseToken(

View file

@ -20,6 +20,8 @@ dependencies {
kapt(deps.hilt.kapt)
/** Domain */
implementation(projects.domain.swap.models)
implementation(projects.domain.swap)
implementation(projects.domain.appCurrency)
implementation(projects.domain.appCurrency.models)
implementation(projects.domain.card)

View file

@ -1,43 +0,0 @@
package com.tangem.feature.swap.domain
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.feature.swap.domain.models.ui.AccountSwapCurrency
import com.tangem.feature.swap.domain.models.ui.TokensDataStateExpress
import com.tangem.feature.swap.domain.models.ui.getGroupWithReverse
import com.tangem.utils.extensions.orZero
internal class DefaultInitialToCurrencyResolver(
private val swapTransactionRepository: SwapTransactionRepository,
) : InitialToCurrencyResolver {
override suspend fun tryGetFromCache(
userWallet: UserWallet,
initialCryptoCurrency: CryptoCurrency,
state: TokensDataStateExpress,
isReverseFromTo: Boolean,
): AccountSwapCurrency? {
val id = swapTransactionRepository.getLastSwappedCryptoCurrencyId(userWallet.walletId) ?: return null
return if (id != initialCryptoCurrency.id.value) {
val group = state.getGroupWithReverse(isReverseFromTo)
group.accountCurrencyList.firstNotNullOfOrNull { (_, currencyList) ->
currencyList.find { it.isAvailable && it.cryptoCurrencyStatus.currency.id.value == id }
}
} else {
null
}
}
override fun tryGetWithMaxAmount(state: TokensDataStateExpress, isReverseFromTo: Boolean): AccountSwapCurrency? {
val group = state.getGroupWithReverse(isReverseFromTo)
return group.accountCurrencyList.firstNotNullOfOrNull { (_, currencyList) ->
currencyList.maxByOrNull { swapAccountCurrency ->
swapAccountCurrency.cryptoCurrencyStatus.value.fiatAmount
.takeIf { swapAccountCurrency.isAvailable }
.orZero()
}
}
}
}

View file

@ -1,18 +0,0 @@
package com.tangem.feature.swap.domain
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.feature.swap.domain.models.ui.AccountSwapCurrency
import com.tangem.feature.swap.domain.models.ui.TokensDataStateExpress
interface InitialToCurrencyResolver {
suspend fun tryGetFromCache(
userWallet: UserWallet,
initialCryptoCurrency: CryptoCurrency,
state: TokensDataStateExpress,
isReverseFromTo: Boolean,
): AccountSwapCurrency?
fun tryGetWithMaxAmount(state: TokensDataStateExpress, isReverseFromTo: Boolean): AccountSwapCurrency?
}

View file

@ -2,87 +2,58 @@ package com.tangem.feature.swap.domain
import arrow.core.Either
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.domain.express.models.ExpressError
import com.tangem.domain.express.models.ExpressOperationType
import com.tangem.domain.models.account.Account
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.UserWalletId
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.models.TransactionFeeExtended
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.IncludeFeeInAmount
import com.tangem.feature.swap.domain.models.domain.PermissionOptions
import com.tangem.feature.swap.domain.models.domain.SwapDataModel
import com.tangem.feature.swap.domain.models.domain.SwapProvider
import com.tangem.feature.swap.domain.models.ui.*
import com.tangem.feature.swap.domain.models.domain.*
import com.tangem.feature.swap.domain.models.ui.SwapState
import com.tangem.feature.swap.domain.models.ui.SwapTransactionState
import com.tangem.feature.swap.domain.models.ui.TxFee
import java.math.BigDecimal
interface SwapInteractor {
suspend fun getTokensDataState(currency: CryptoCurrency): TokensDataStateExpress
suspend fun getPair(
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,
filterProviderTypes: List<ExchangeProviderType>,
): Either<ExpressError, List<SwapPairLeast>>
/**
* Gives permission to swap, this starts scan card process
*
* @param networkId network in which selected token
* @param permissionOptions data to give permissions
*/
@Throws(IllegalStateException::class)
suspend fun givePermissionToSwap(networkId: String, permissionOptions: PermissionOptions): SwapTransactionState
suspend fun findProvidersForPairWithCheck(
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,
pairs: List<SwapPairLeast>,
): List<SwapProvider>
fun findProvidersForPair(
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,
pairs: List<SwapPairLeast>,
): List<SwapProvider>
/**
* Find best quote for given tokens to swap
* under the hood calls different methods to receive data, depends on permission for given token
*
* @param fromToken token from which want to swap
* @param fromAccount account from which swap will be made
* @param toToken token that receive after swap
* @param toAccount account to which receive token after swap
* @param providers list of providers to find quote
* @param amountToSwap amount you want to swap
* @param reduceBalanceBy amount to reduce from balance (used for fee calculation)
* @param txFeeSealedState selected fee to swap
* @return
*/
@Suppress("LongParameterList")
@Throws(IllegalStateException::class)
suspend fun findBestQuote(
fromToken: CryptoCurrencyStatus,
fromAccount: Account?,
toToken: CryptoCurrencyStatus,
toAccount: Account?,
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,
providers: List<SwapProvider>,
amountToSwap: String,
reduceBalanceBy: BigDecimal,
txFeeSealedState: TxFeeSealedState,
): Map<SwapProvider, SwapState>
/**
* Starts swap transaction, perform sign transaction
*
* @param swapProvider swap provider to use
* @param swapData tx data to swap, contains data to sign
* @param currencyToSend crypto currency to send
* @param currencyToGet crypto currency to get
* @param fromAccount account from which swap will be made
* @param toAccount account to which receive token
* @param amountToSwap amount to swap
* @param includeFeeInAmount flag to include fee in amount
* @param fee for tx (can be null only for tangem pay withdrawal)
* @param expressOperationType type of express operation
* @return [SwapTransactionState]
*/
@Suppress("LongParameterList")
@Throws(IllegalStateException::class)
suspend fun onSwap(
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,
swapProvider: SwapProvider,
swapData: SwapDataModel?,
currencyToSend: CryptoCurrencyStatus,
currencyToGet: CryptoCurrencyStatus,
fromAccount: Account?,
toAccount: Account?,
amountToSwap: String,
includeFeeInAmount: IncludeFeeInAmount,
fee: TxFee?,
@ -90,14 +61,6 @@ interface SwapInteractor {
isTangemPayWithdrawal: Boolean,
): SwapTransactionState
// suspend fun updateQuotesStateWithSelectedFee(
// state: SwapState.QuotesLoadedState,
// selectedFee: FeeType,
// fromToken: CryptoCurrencyStatus,
// amountToSwap: String,
// reduceBalanceBy: BigDecimal,
// ): SwapState.QuotesLoadedState
/**
* Returns token in wallet balance
*
@ -105,27 +68,12 @@ interface SwapInteractor {
*/
fun getTokenBalance(token: CryptoCurrencyStatus): SwapAmount
/**
* Returns initial currency to swap as AccountSwapCurrency
*
* @param initialCryptoCurrency initial currency selected to swap
* @param state current tokens data state
* @param isReverseFromTo flag indicating the direction of the swap
*/
suspend fun getInitialCurrencyToSwap(
initialCryptoCurrency: CryptoCurrency,
state: TokensDataStateExpress,
isReverseFromTo: Boolean,
): AccountSwapCurrency?
suspend fun getNativeToken(network: Network): CryptoCurrency
suspend fun getNativeToken(swapCurrencyStatus: SwapCurrencyStatus): CryptoCurrency
@Suppress("LongParameterList")
suspend fun storeSwapTransaction(
currencyToSend: CryptoCurrencyStatus,
currencyToGet: CryptoCurrencyStatus,
fromAccount: Account?,
toAccount: Account?,
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,
amount: SwapAmount,
swapProvider: SwapProvider,
swapDataModel: SwapDataModel,
@ -135,51 +83,19 @@ interface SwapInteractor {
averageDuration: Int? = null,
)
/**
* Loads fee for swap transaction
*
* @param fromToken token from which want to swap
* @param fromAccount account from which swap will be made
* @param toToken token that receive after swap
* @param toAccount account to which receive token after swap
* @param amount amount you want to swap
* @param reduceBalanceBy amount to reduce from balance (used for fee calculation)
* @param selectedFeeToken selected token to pay fee or null to pay fee with coin
*/
@Suppress("LongParameterList")
suspend fun loadFeeForSwapTransaction(
fromToken: CryptoCurrencyStatus,
fromAccount: Account?,
toToken: CryptoCurrencyStatus,
toAccount: Account?,
fromSwapCurrencyStatus: SwapCurrencyStatus,
amount: String,
reduceBalanceBy: BigDecimal,
provider: SwapProvider,
selectedFeeToken: CryptoCurrencyStatus?,
): Either<GetFeeError, TransactionFeeExtended>
/**
* Loads fee for swap transaction
*
* @param fromToken token from which want to swap
* @param fromAccount account from which swap will be made
* @param toToken token that receive after swap
* @param toAccount account to which receive token after swap
* @param amount amount you want to swap
* @param reduceBalanceBy amount to reduce from balance (used for fee calculation)
*/
@Suppress("LongParameterList")
suspend fun loadFeeForSwapTransaction(
fromToken: CryptoCurrencyStatus,
fromAccount: Account?,
toToken: CryptoCurrencyStatus,
toAccount: Account?,
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,
amount: String,
reduceBalanceBy: BigDecimal,
provider: SwapProvider,
): Either<GetFeeError, TransactionFee>
interface Factory {
fun create(selectedWalletId: UserWalletId): SwapInteractor
}
}

View file

@ -14,7 +14,8 @@ interface SwapTransactionRepository {
@Suppress("LongParameterList")
suspend fun storeTransaction(
userWalletId: UserWalletId,
fromUserWalletId: UserWalletId,
toUserWalletId: UserWalletId,
fromCryptoCurrency: CryptoCurrency,
toCryptoCurrency: CryptoCurrency,
fromAccount: Account?,

View file

@ -4,6 +4,7 @@ import arrow.core.Either
import com.tangem.domain.express.models.ExpressOperationType
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.feature.swap.domain.models.ExpressDataError
import com.tangem.feature.swap.domain.models.domain.*
@ -23,7 +24,11 @@ interface SwapRepository {
isIgnoreExpress: Boolean = false,
): PairsWithProviders
suspend fun getExchangeStatus(userWallet: UserWallet, txId: String): Either<UnknownError, ExchangeStatusModel>
suspend fun getExchangeStatus(
userWallet: UserWallet?,
userWalletId: UserWalletId,
txId: String,
): Either<UnknownError, ExchangeStatusModel>
@Suppress("LongParameterList")
suspend fun findBestQuote(

View file

@ -1,6 +1,10 @@
package com.tangem.feature.swap.domain.di
import com.tangem.feature.swap.domain.*
import com.tangem.feature.swap.domain.AllowPermissionsHandler
import com.tangem.feature.swap.domain.AllowPermissionsHandlerImpl
import com.tangem.feature.swap.domain.SwapInteractor
import com.tangem.feature.swap.domain.SwapInteractorImpl
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -16,20 +20,13 @@ internal class SwapDomainModule {
fun provideAllowPermissionsHandler(): AllowPermissionsHandler {
return AllowPermissionsHandlerImpl()
}
@Provides
@Singleton
fun provideSwapInteractorFactory(factory: SwapInteractorImpl.Factory): SwapInteractor.Factory {
return factory
}
@Provides
@Module
@InstallIn(SingletonComponent::class)
internal interface SwapDomainBindModule {
@Binds
@Singleton
fun provideInitialToCurrencyResolver(
swapTransactionRepository: SwapTransactionRepository,
): InitialToCurrencyResolver {
return DefaultInitialToCurrencyResolver(
swapTransactionRepository = swapTransactionRepository,
)
}
fun provideSwapInteractor(swapInteractor: SwapInteractorImpl): SwapInteractor
}

View file

@ -1,6 +0,0 @@
package com.tangem.feature.swap.domain.models.domain
data class NetworkInfo(
val name: String,
val blockchainId: String,
)

View file

@ -1,23 +0,0 @@
package com.tangem.feature.swap.domain.models.domain
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.feature.swap.domain.models.ui.RequestApproveStateData
import com.tangem.feature.swap.domain.models.ui.TxFee
/**
* Permission options
*
* @param approveData tx data to give approve, it loaded from 1inch in findBestQuote if needed
* @param forTokenContractAddress token contract address for which needs permission
* @param fromTokenStatus which token will be swapping
* @param approveType unlimited or tx amount approve
* @param txFee fee for tx
*/
data class PermissionOptions(
val approveData: RequestApproveStateData,
val forTokenContractAddress: String,
val fromTokenStatus: CryptoCurrencyStatus,
val spenderAddress: String,
val approveType: SwapApproveType,
val txFee: TxFee,
)

View file

@ -5,12 +5,10 @@ import com.tangem.feature.swap.domain.models.SwapAmount
/**
* Prepared swap config state that contains flags to determine
*
* @property isAllowedToSpend shows is token allowed to spend
* @property isBalanceEnough shows is balance of token enough
*/
// todo Refactor this state
data class PreparedSwapConfigState(
val isAllowedToSpend: Boolean,
val isBalanceEnough: Boolean,
val feeState: SwapFeeState,
val hasOutgoingTransaction: Boolean,

View file

@ -8,7 +8,8 @@ import com.tangem.domain.models.currency.CryptoCurrency
import java.math.BigDecimal
data class SavedSwapTransactionListModel(
val userWalletId: String,
val fromUserWalletId: String,
val toUserWalletId: String,
val fromCryptoCurrencyId: String,
val toCryptoCurrencyId: String,
val fromCryptoCurrency: CryptoCurrency,
@ -25,7 +26,9 @@ data class SavedSwapTransactionListModel(
@JsonClass(generateAdapter = true)
data class SavedSwapTransactionListModelInner(
@Json(name = "userWalletId")
val userWalletId: String,
val fromUserWalletId: String,
@Json(name = "toUserWalletId")
val toUserWalletId: String = fromUserWalletId,
@Json(name = "fromCryptoCurrencyId")
val fromCryptoCurrencyId: String,
@Json(name = "toCryptoCurrencyId")

View file

@ -1,12 +0,0 @@
package com.tangem.feature.swap.domain.models.domain
enum class SwapApproveType {
LIMITED, UNLIMITED
}
fun SwapApproveType.getNameForAnalytics(): String {
return when (this) {
SwapApproveType.LIMITED -> "Transaction"
SwapApproveType.UNLIMITED -> "Unlimited"
}
}

View file

@ -1,11 +1,8 @@
package com.tangem.feature.swap.domain.models.domain
import com.tangem.domain.models.currency.CryptoCurrency
sealed class SwapFeeState {
data object Enough : SwapFeeState()
data class NotEnough(
val feeCurrency: CryptoCurrency? = null,
val currencyName: String? = null,
val currencySymbol: String? = null,
) : SwapFeeState()

View file

@ -59,11 +59,21 @@ data class SwapProvider(
@JsonClass(generateAdapter = false)
enum class ExchangeProviderType(val providerName: String) {
@Json(name = "DEX") DEX("DEX"),
@Json(name = "DEX")
DEX("DEX"),
@Json(name = "CEX") CEX("CEX"),
@Json(name = "CEX")
CEX("CEX"),
@Json(name = "DEX_BRIDGE") DEX_BRIDGE("DEX/Bridge"),
@Json(name = "DEX_BRIDGE")
DEX_BRIDGE("DEX/Bridge"),
;
companion object {
fun getSwapProviderTypes(): List<ExchangeProviderType> {
return listOf(CEX, DEX, DEX_BRIDGE)
}
}
}
/**
@ -73,7 +83,9 @@ enum class ExchangeProviderType(val providerName: String) {
*/
@JsonClass(generateAdapter = false)
enum class RateType {
@Json(name = "FLOAT") FLOAT,
@Json(name = "FLOAT")
FLOAT,
@Json(name = "FIXED") FIXED,
@Json(name = "FIXED")
FIXED,
}

View file

@ -3,8 +3,8 @@ package com.tangem.feature.swap.domain.models.ui
import androidx.compose.runtime.Immutable
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck
import com.tangem.feature.swap.domain.TransactionFeeResult
import com.tangem.feature.swap.domain.models.ExpressDataError
@ -23,7 +23,6 @@ sealed interface SwapState {
val toTokenInfo: TokenSwapInfo,
val priceImpact: PriceImpact,
val preparedSwapConfigState: PreparedSwapConfigState = PreparedSwapConfigState(
isAllowedToSpend = false,
isBalanceEnough = false,
feeState = SwapFeeState.NotEnough(),
hasOutgoingTransaction = false,
@ -81,17 +80,11 @@ data class PriceImpact(
sealed class PermissionDataState {
data class PermissionReadyForRequest(
val currency: String,
val amount: String,
val walletAddress: String,
val spenderAddress: String,
val requestApproveData: RequestApproveStateData,
data class PermissionRequired(
val isResetApproval: Boolean,
val spenderAddress: String,
) : PermissionDataState()
object PermissionFailed : PermissionDataState()
object PermissionLoading : PermissionDataState()
object Empty : PermissionDataState()
@ -100,8 +93,7 @@ sealed class PermissionDataState {
data class TokenSwapInfo(
val tokenAmount: SwapAmount,
val amountFiat: BigDecimal,
val cryptoCurrencyStatus: CryptoCurrencyStatus,
val account: Account?,
val swapCurrencyStatus: SwapCurrencyStatus,
)
data class RequestApproveStateData(

View file

@ -1,9 +1,8 @@
package com.tangem.feature.swap.domain.models.ui
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.pay.TangemPayWithdrawExchangeState
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.domain.transaction.error.SendTransactionError
import com.tangem.feature.swap.domain.models.ExpressDataError
import com.tangem.feature.swap.domain.models.SwapAmount
@ -36,10 +35,8 @@ sealed class SwapTransactionState {
) : SwapTransactionState() {
data class StoreTransactionData(
val currencyToSend: CryptoCurrencyStatus,
val currencyToGet: CryptoCurrencyStatus,
val fromAccount: Account?,
val toAccount: Account?,
val fromSwapCurrencyStatus: SwapCurrencyStatus,
val toSwapCurrencyStatus: SwapCurrencyStatus,
val amount: SwapAmount,
val swapProvider: SwapProvider,
val swapDataModel: SwapDataModel,

View file

@ -11,6 +11,10 @@ android {
namespace = "com.tangem.feature.swap.presentation"
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
/** Api */
implementation(projects.features.commonFeatures.api)
@ -59,6 +63,8 @@ dependencies {
implementation(projects.domain.account.status)
implementation(projects.domain.visa)
implementation(projects.domain.markets)
implementation(projects.domain.swap)
implementation(projects.domain.swap.models)
/** Feature modules */
implementation(projects.features.swap.domain)
@ -106,4 +112,8 @@ dependencies {
/** DI */
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
/** Test */
testImplementation(projects.test.core)
testRuntimeOnly(deps.test.junit5.engine)
}

View file

@ -1,30 +1,37 @@
package com.tangem.feature.swap
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.background
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.arkivanov.decompose.extensions.compose.stack.Children
import com.arkivanov.decompose.extensions.compose.stack.animation.fade
import com.arkivanov.decompose.extensions.compose.stack.animation.stackAnimation
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
import com.arkivanov.decompose.router.slot.childSlot
import com.arkivanov.decompose.router.slot.dismiss
import com.arkivanov.decompose.router.stack.StackNavigation
import com.arkivanov.decompose.router.stack.childStack
import com.arkivanov.decompose.router.stack.pop
import com.arkivanov.essenty.lifecycle.subscribe
import com.tangem.common.ui.bottomsheet.permission.state.GiveTxPermissionState
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.child
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.decompose.navigation.inner.InnerRouter
import com.tangem.core.ui.R
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.res.TangemTheme
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.isHotWallet
import com.tangem.feature.swap.choosetoken.api.ChooseTokenComponent
import com.tangem.feature.swap.component.SwapFeeSelectorBlockComponent
import com.tangem.feature.swap.model.SwapModel
import com.tangem.feature.swap.router.SwapNavScreen
import com.tangem.feature.swap.models.SwapPermissionUM
import com.tangem.feature.swap.router.SwapRoute
import com.tangem.feature.swap.ui.SwapScreen
import com.tangem.feature.swap.ui.SwapSuccessScreen
import com.tangem.features.approval.api.GiveApprovalComponent
@ -46,17 +53,24 @@ internal class DefaultSwapComponent @AssistedInject constructor(
private val chooseTokenComponentFactory: ChooseTokenComponent.Factory,
) : SwapComponent, AppComponentContext by appComponentContext {
private val model: SwapModel = getOrCreateModel(params)
// todo swap create InnerRouter
private val chooseTokenComponent by lazy {
chooseTokenComponentFactory.create(
context = child("chooseTokenComponent"),
params = ChooseTokenComponent.Params(
bridge = model.chooseTokenBridge,
),
private val stackNavigation = StackNavigation<SwapRoute>()
private val innerRouter = InnerRouter<SwapRoute>(
stackNavigation = stackNavigation,
popCallback = { onChildBack() },
)
private val model: SwapModel = getOrCreateModel(params, router = innerRouter)
private val childStack = childStack(
key = STACK_KEY,
source = stackNavigation,
serializer = null,
initialConfiguration = SwapRoute.Main,
handleBackButton = true,
childFactory = { route, factoryContext ->
createChild(route, childByContext(factoryContext))
},
)
}
private val approvalSlot = childSlot(
key = APPROVAL_SLOT_KEY,
@ -81,7 +95,7 @@ internal class DefaultSwapComponent @AssistedInject constructor(
}
private val slotNavigation = SlotNavigation<FeeSelectorConfig>()
private val childSlot = childSlot(
private val feeSelectorSlot = childSlot(
source = slotNavigation,
serializer = null,
key = FEE_SELECTOR_SLOT_KEY,
@ -112,6 +126,19 @@ internal class DefaultSwapComponent @AssistedInject constructor(
)
}
private fun createChild(route: SwapRoute, factoryContext: AppComponentContext): ComposableContentComponent =
when (route) {
is SwapRoute.Main -> SwapMainChild()
is SwapRoute.Success -> SwapSuccessChild()
is SwapRoute.SelectToken -> {
val bridge = if (route.isFromDirection) model.chooseFromTokenBridge else model.chooseToTokenBridge
chooseTokenComponentFactory.create(
context = factoryContext,
params = ChooseTokenComponent.Params(bridge = bridge),
)
}
}
data class FeeSelectorConfig(
val sendingCurrencyStatus: CryptoCurrencyStatus,
val feeCurrencyStatus: CryptoCurrencyStatus,
@ -121,7 +148,7 @@ internal class DefaultSwapComponent @AssistedInject constructor(
@Composable
override fun Content(modifier: Modifier) {
val dataState by model.dataStateStateFlow.collectAsStateWithLifecycle()
val fromCryptoCurrency by remember { derivedStateOf { dataState.fromCryptoCurrency } }
val fromCryptoCurrency by remember { derivedStateOf { dataState.fromSwapCurrencyStatus?.status } }
val feePaidCryptoCurrency by remember { derivedStateOf { dataState.feePaidCryptoCurrency } }
val shouldHideBlock by remember {
derivedStateOf { toBigDecimalOrZero(dataState.amount).isZero() || model.uiState.isInsufficientFunds }
@ -158,67 +185,78 @@ internal class DefaultSwapComponent @AssistedInject constructor(
)
}
val feeSelectorChildStackState by childSlot.subscribeAsState()
val feeSelectorBlockComponent = feeSelectorChildStackState.child?.instance
val stackState by childStack.subscribeAsState()
Crossfade(
Children(
stack = stackState,
modifier = Modifier.background(TangemTheme.colors.background.secondary),
targetState = model.currentScreen,
label = "",
) { screen ->
when (screen) {
SwapNavScreen.Main -> SwapScreen(
stateHolder = model.uiState,
feeSelectorBlockComponent = feeSelectorBlockComponent,
)
SwapNavScreen.Success -> {
val successState = model.uiState.successState
val feeSelectorState by model.feeSelectorRepository.state.collectAsStateWithLifecycle()
if (successState != null) {
SwapSuccessScreen(
state = successState,
feeSelectorUM = feeSelectorState,
onBack = model.uiState.onBackClicked,
)
} else {
SwapScreen(
stateHolder = model.uiState,
feeSelectorBlockComponent = feeSelectorBlockComponent,
)
}
}
SwapNavScreen.SelectToken -> chooseTokenComponent.Content(Modifier)
}
animation = stackAnimation { fade() },
) { child ->
child.instance.Content(Modifier)
}
val approvalSlotState by approvalSlot.subscribeAsState()
approvalSlotState.child?.instance?.BottomSheet()
}
fun getApprovalParams(): GiveApprovalComponent.Params? {
val permissionState = model.uiState.permissionState as? GiveTxPermissionState.ReadyForRequest
?: return null
val fromCryptoCurrency = model.dataState.fromCryptoCurrency ?: return null
private inner class SwapMainChild : ComposableContentComponent {
@Composable
override fun Content(modifier: Modifier) {
val feeSelectorChildState by feeSelectorSlot.subscribeAsState()
val feeSelectorBlockComponent = feeSelectorChildState.child?.instance
SwapScreen(
stateHolder = model.uiState,
feeSelectorBlockComponent = feeSelectorBlockComponent,
)
}
}
private inner class SwapSuccessChild : ComposableContentComponent {
@Composable
override fun Content(modifier: Modifier) {
val successState = model.uiState.successState
val feeSelectorState by model.feeSelectorRepository.state.collectAsStateWithLifecycle()
if (successState != null) {
SwapSuccessScreen(
state = successState,
feeSelectorUM = feeSelectorState,
onBack = router::pop,
)
} else {
val feeSelectorChildState by feeSelectorSlot.subscribeAsState()
val feeSelectorBlockComponent = feeSelectorChildState.child?.instance
SwapScreen(
stateHolder = model.uiState,
feeSelectorBlockComponent = feeSelectorBlockComponent,
)
}
}
}
private fun getApprovalParams(): GiveApprovalComponent.Params? {
val permissionState = model.uiState.permissionUM as? SwapPermissionUM.PermissionRequired ?: return null
val fromSwapCurrencyStatus = model.dataState.fromSwapCurrencyStatus ?: return null
val feeCryptoCurrency = model.dataState.feePaidCryptoCurrency ?: return null
val providerName = model.dataState.selectedProvider?.name.orEmpty()
val isHoldToConfirm = fromSwapCurrencyStatus.userWallet.isHotWallet
return GiveApprovalComponent.Params(
userWalletId = params.userWalletId,
cryptoCurrencyStatus = fromCryptoCurrency,
cryptoCurrencyStatus = fromSwapCurrencyStatus.status,
feeCryptoCurrencyStatus = feeCryptoCurrency,
amount = model.dataState.amount.orEmpty(),
spenderAddress = requireNotNull(model.dataState.approveDataModel).spenderAddress,
spenderAddress = permissionState.spenderAddress,
amountFooter = if (permissionState.isResetApproval) {
resourceReference(R.string.update_approval_permission_subtitle)
} else {
resourceReference(
id = R.string.give_permission_swap_subtitle,
formatArgs = wrappedList(providerName, permissionState.currency),
formatArgs = wrappedList(providerName, fromSwapCurrencyStatus.currency.symbol),
)
},
feeFooter = resourceReference(R.string.swap_give_permission_fee_footer),
isResetApproval = permissionState.isResetApproval,
isHoldToConfirm = model.isHoldToConfirmEnabled,
isHoldToConfirm = isHoldToConfirm,
callback = model.approvalCallback,
)
}
@ -227,13 +265,24 @@ internal class DefaultSwapComponent @AssistedInject constructor(
return bigDecimalString?.replace(",", ".")?.toBigDecimalOrNull() ?: BigDecimal.ZERO
}
private fun onChildBack() {
val isEmptyStack = childStack.value.backStack.isEmpty()
val isSuccess = model.uiState.successState != null
val isPopSend = isEmptyStack || isSuccess
when {
isPopSend -> router.pop()
else -> stackNavigation.pop()
}
}
@AssistedFactory
interface Factory : SwapComponent.Factory {
override fun create(context: AppComponentContext, params: SwapComponent.Params): DefaultSwapComponent
}
private companion object {
const val BOTTOM_SHEET_SLOT_KEY = "bottomSheetSlot"
const val STACK_KEY = "swapStack"
const val FEE_SELECTOR_SLOT_KEY = "feeSelectorSlot"
const val APPROVAL_SLOT_KEY = "approvalSlot"
}

View file

@ -1,6 +1,5 @@
package com.tangem.feature.swap.analytics
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam.Key.ACCOUNT_DERIVATION_FROM
import com.tangem.core.analytics.models.AnalyticsParam.Key.ACCOUNT_DERIVATION_TO
@ -38,11 +37,6 @@ sealed class SwapEvents(
class SendTokenBalanceClicked : SwapEvents(event = "Send Token Balance Clicked")
class ChooseTokenScreenOpened(val hasAvailableTokens: Boolean) : SwapEvents(
event = "Choose Token Screen Opened",
params = mapOf("Available tokens" to if (hasAvailableTokens) "Yes" else "No"),
)
class ChooseTokenScreenResult(
val isTokenChosen: Boolean,
val token: String? = null,
@ -72,23 +66,6 @@ sealed class SwapEvents(
),
)
class ButtonPermissionApproveClicked(
val sendToken: String,
val receiveToken: String,
val approveType: ApproveType,
val provider: SwapProvider,
) : SwapEvents(
event = "Button - Permission Approve",
params = mapOf(
"Send Token" to sendToken,
"Receive Token" to receiveToken,
"Type" to if (approveType == ApproveType.LIMITED) "Current Transaction" else "Unlimited",
"Provider" to provider.name,
),
)
class ButtonPermissionCancelClicked : SwapEvents(event = "Button - Permission Cancel")
class ButtonSwipeClicked : SwapEvents(event = "Button - Swipe")
@Suppress("NullableToStringCall", "LongParameterList")

View file

@ -39,7 +39,7 @@ interface ChooseTokenBridge : ChooseTokenBridgeLegacy, ChooseTokenBridgeInternal
companion object {
val SwapFrom = Settings(
title = resourceReference(R.string.swapping_from_title),
isShowMarketBlock = false,
isShowMarketBlock = true,
isShowPaymentAccount = true,
)
val SwapTo = Settings(

View file

@ -13,6 +13,7 @@ import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.feature.swap.choosetoken.api.ChooseTokenComponent
import com.tangem.feature.swap.choosetoken.impl.model.ChooseTokenModel
import com.tangem.feature.swap.choosetoken.impl.ui.ChooseTokenScreen
import com.tangem.feature.swap.models.AddToPortfolioRoute
import com.tangem.feature.swap.ui.SwapSelectTokenScreen
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioComponent
@ -42,10 +43,13 @@ internal class DefaultChooseTokenComponent @AssistedInject constructor(
val bottomSheet by bottomSheetSlot.subscribeAsState()
stateOld?.let { stateHolder ->
SwapSelectTokenScreen(state = stateHolder, onBack = { model.onBackClicked() })
bottomSheet.child?.instance?.BottomSheet()
// if old shown we should not show new screen
return
}
// todo swap uncomment
// val state by model.state.collectAsStateWithLifecycle()
// ChooseTokenScreen(state = state)
val state by model.state.collectAsStateWithLifecycle()
ChooseTokenScreen(state = state)
bottomSheet.child?.instance?.BottomSheet()
}

View file

@ -0,0 +1,225 @@
package com.tangem.feature.swap.model
import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
import com.tangem.domain.exchange.RampStateManager
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.account.PaymentAccountStatusValue
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.features.swap.SwapComponent.Params.CurrencyPosition
import com.tangem.utils.extensions.orZero
import com.tangem.utils.isNullOrZero
import javax.inject.Inject
/**
* Resolves the initial FROM and TO currencies when the swap screen opens.
*
* Selection rules when [initialCryptoCurrency][CryptoCurrency] is provided:
* - [CurrencyPosition.FROM] places the currency as FROM, TO is null.
* - [CurrencyPosition.TO] places the currency as TO, FROM is null.
* - [CurrencyPosition.ANY] auto-places based on availability and balance:
* - available with balance FROM.
* - available without balance or unavailable without balance TO,
* and the best candidate from the SAME account as the initial currency is selected as FROM
* (the search is scoped to that account only, not the whole portfolio).
* - unavailable with balance FROM.
*
* When no initial currency is provided, selects the best token from crypto portfolio accounts:
* 1. If available tokens with balance exist the available token with the highest fiat balance.
* 2. If available tokens exist but none have balance the first token from the first account.
* 3. If no available tokens exist but tokens with balance exist the token with the highest fiat balance.
* 4. If no tokens have balance the first token from the first account.
*/
internal class InitialCurrenciesResolver @Inject constructor(
private val getUserWalletUseCase: GetUserWalletUseCase,
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
private val rampStateManager: RampStateManager,
) {
/**
* Resolves the initial FROM/TO currency pair for the swap screen.
*
* @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
* @return pair of (from, to) [SwapCurrencyStatus]; either or both may be null
*/
suspend operator fun invoke(
userWalletId: UserWalletId,
initialCryptoCurrency: CryptoCurrency?,
swapCurrencyPosition: CurrencyPosition,
isPaymentAccount: Boolean,
): Pair<SwapCurrencyStatus?, SwapCurrencyStatus?> {
val walletAccountList = getWalletAccountCurrencyStatusList(userWalletId)
val cryptoPortfolioAccounts = walletAccountList.filterKeys { accountStatus ->
accountStatus is AccountStatus.CryptoPortfolio
}.mapKeys { (key, _) -> key as AccountStatus.CryptoPortfolio }
val cryptoPaymentAccounts = walletAccountList.filterKeys { accountStatus ->
accountStatus is AccountStatus.Payment
}
val cryptoCurrencyList = cryptoPortfolioAccounts.values.flatten()
return if (initialCryptoCurrency != null) {
val selectedSwapCurrencyStatus = if (isPaymentAccount) {
cryptoPaymentAccounts
} else {
cryptoPortfolioAccounts
}.firstNotNullOfOrNull { (_, currencyList) ->
currencyList.firstOrNull { currencyStatus ->
currencyStatus.currency.id == initialCryptoCurrency.id
}
}
if (selectedSwapCurrencyStatus == null) {
null to null
} else {
placeSelectedCurrency(
selectedSwapCurrencyStatus = selectedSwapCurrencyStatus,
swapCurrencyPosition = swapCurrencyPosition,
cryptoPortfolioAccountsMap = cryptoPortfolioAccounts,
)
}
} else {
selectCryptoCurrency(
cryptoPortfolioAccountsMap = cryptoPortfolioAccounts,
cryptoCurrencyList = cryptoCurrencyList,
) to null
}
}
/**
* Builds a map of [AccountStatus] to their [SwapCurrencyStatus] lists,
* enriching each currency with its swap availability from [RampStateManager].
*/
private suspend fun getWalletAccountCurrencyStatusList(
userWalletId: UserWalletId,
): Map<AccountStatus, List<SwapCurrencyStatus>> {
val userWallet = getUserWalletUseCase(userWalletId).getOrNull() ?: return emptyMap()
val walletAccountCurrencyStatuses = singleAccountStatusListSupplier.getSyncOrNull(
SingleAccountStatusListProducer.Params(userWalletId),
)?.accountStatuses.orEmpty()
return walletAccountCurrencyStatuses.associateWith { accountStatus ->
val currencyStatuses = when (accountStatus) {
is AccountStatus.CryptoPortfolio -> accountStatus.flattenCurrencies()
is AccountStatus.Payment -> getPaymentAccountCurrencies(accountStatus)
}
val availabilityStates = rampStateManager.availableForSwap(
userWalletId,
currencyStatuses.map { it.currency },
)
currencyStatuses.map { cryptoCurrencyStatus ->
SwapCurrencyStatus(
userWallet = userWallet,
account = accountStatus.account,
status = cryptoCurrencyStatus,
isAvailableForSwap = availabilityStates[cryptoCurrencyStatus.currency] ==
ScenarioUnavailabilityReason.None,
)
}
}
}
private fun getPaymentAccountCurrencies(accountStatus: AccountStatus.Payment): List<CryptoCurrencyStatus> {
val paymentCryptoCurrencyStatus = when (val statusValue = accountStatus.value) {
is PaymentAccountStatusValue.Loaded -> statusValue.cryptoCurrencyStatus
else -> null
}
return listOfNotNull(paymentCryptoCurrencyStatus)
}
/**
* Places the [selectedSwapCurrencyStatus] into the FROM or TO slot based on [swapCurrencyPosition].
*
* For [CurrencyPosition.ANY], the position is determined by availability and balance:
* currencies that are available with balance go to FROM; otherwise, the selected currency
* goes to TO and a best-candidate FROM is resolved via [selectCryptoCurrency] scoped to the
* SAME account that the selected currency belongs to, so we never pull a FROM candidate from a
* different account in the portfolio.
*/
private fun placeSelectedCurrency(
selectedSwapCurrencyStatus: SwapCurrencyStatus,
swapCurrencyPosition: CurrencyPosition,
cryptoPortfolioAccountsMap: Map<AccountStatus.CryptoPortfolio, List<SwapCurrencyStatus>>,
): Pair<SwapCurrencyStatus?, SwapCurrencyStatus?> {
return when (swapCurrencyPosition) {
CurrencyPosition.FROM -> {
selectedSwapCurrencyStatus to null
}
CurrencyPosition.TO -> {
null to selectedSwapCurrencyStatus
}
CurrencyPosition.ANY -> {
val isAvailable = selectedSwapCurrencyStatus.isAvailableForSwap
val hasBalance = !selectedSwapCurrencyStatus.status.value.fiatAmount.isNullOrZero()
if (isAvailable && hasBalance) {
selectedSwapCurrencyStatus to null
} else if (isAvailable || !hasBalance) {
val selectedCurrency = selectedSwapCurrencyStatus.currency
val selectedAccountId = selectedSwapCurrencyStatus.account.accountId
val sameAccountEntry = cryptoPortfolioAccountsMap.entries
.firstOrNull { (accountStatus, _) -> accountStatus.account.accountId == selectedAccountId }
if (sameAccountEntry == null) {
null to selectedSwapCurrencyStatus
} else {
val scopedList = sameAccountEntry.value
.filterNot { it.currency.isSameTokenAs(selectedCurrency) }
selectCryptoCurrency(
cryptoPortfolioAccountsMap = mapOf(sameAccountEntry.key to scopedList),
cryptoCurrencyList = scopedList,
) to selectedSwapCurrencyStatus
}
} else {
selectedSwapCurrencyStatus to null
}
}
}
}
/**
* Checks whether two currencies refer to the same asset on the same network, regardless of the
* owning account. Two instances of the same token in different accounts have distinct
* [CryptoCurrency.ID] values (their derivation path differs), so id equality is not sufficient
* to detect duplicates when auto-picking a FROM candidate.
*/
private fun CryptoCurrency.isSameTokenAs(other: CryptoCurrency): Boolean {
return id.rawNetworkId == other.id.rawNetworkId &&
id.contractAddress == other.id.contractAddress
}
/**
* Selects the best token from the crypto portfolio when no initial currency is specified.
*
* Prioritizes available-for-swap tokens. Among the candidates, picks the one with the highest
* [fiatAmount][CryptoCurrencyStatus.Value.fiatAmount]. Falls back to the first token from the
* first account if no candidate has a positive balance.
*/
private fun selectCryptoCurrency(
cryptoPortfolioAccountsMap: Map<AccountStatus.CryptoPortfolio, List<SwapCurrencyStatus>>,
cryptoCurrencyList: List<SwapCurrencyStatus>,
): SwapCurrencyStatus? {
return if (cryptoCurrencyList.isEmpty()) {
null
} else {
val hasAvailable = cryptoCurrencyList.any { it.isAvailableForSwap }
val candidates = if (hasAvailable) {
cryptoCurrencyList.filter { it.isAvailableForSwap }
} else {
cryptoCurrencyList
}
candidates
.filter { !it.status.value.fiatAmount.isNullOrZero() }
.maxByOrNull { it.status.value.fiatAmount.orZero() }
?: cryptoPortfolioAccountsMap.entries.firstOrNull()?.value?.firstOrNull()
}
}
}

View file

@ -37,15 +37,6 @@ internal class SwapNotificationsFactory(
private val iGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork,
) {
fun getInitialErrorStateNotifications(code: Int, onRefreshClick: () -> Unit): ImmutableList<NotificationUM> {
return persistentListOf(
SwapNotificationUM.Warning.ExpressGeneralError(
code = code,
onConfirmClick = onRefreshClick,
),
)
}
fun getGeneralErrorStateNotifications(
message: TextReference?,
onClick: () -> Unit,
@ -104,7 +95,6 @@ internal class SwapNotificationsFactory(
@Suppress("LongParameterList")
fun getConfirmationStateNotifications(
quoteModel: SwapState.QuotesLoadedState,
fromToken: CryptoCurrency,
feeCryptoCurrencyStatus: CryptoCurrencyStatus?,
selectedFeeType: FeeType,
providerName: String,
@ -114,9 +104,9 @@ internal class SwapNotificationsFactory(
maybeAddRentExemptionError(quoteModel)
maybeAddDomainWarnings(quoteModel, feeCryptoCurrencyStatus, selectedFeeType)
maybeAddNeedReserveToCreateAccountWarning(quoteModel)
maybeAddPermissionNeededWarning(quoteModel, fromToken, providerName)
maybeAddPermissionNeededWarning(quoteModel, providerName)
maybeAddNetworkFeeCoverageWarning(quoteModel, selectedFeeType)
maybeAddUnableCoverFeeWarning(quoteModel, fromToken, hideFee)
maybeAddUnableCoverFeeWarning(quoteModel, feeCryptoCurrencyStatus, hideFee)
maybeAddTransactionInProgressWarning(quoteModel)
maybeAddPriceImpactNotification(quoteModel.priceImpact)
}
@ -135,7 +125,7 @@ internal class SwapNotificationsFactory(
if (quoteModel.permissionState is PermissionDataState.PermissionLoading) {
add(SwapNotificationUM.Error.ApprovalInProgressWarning)
} else if (quoteModel.preparedSwapConfigState.hasOutgoingTransaction) {
val fromCurrency = quoteModel.fromTokenInfo.cryptoCurrencyStatus.currency
val fromCurrency = quoteModel.fromTokenInfo.swapCurrencyStatus.currency
add(
SwapNotificationUM.Error.TransactionInProgressWarning(
currencySymbol = fromCurrency.network.currencySymbol,
@ -162,7 +152,7 @@ internal class SwapNotificationsFactory(
feeCryptoCurrencyStatus: CryptoCurrencyStatus?,
selectedFeeType: FeeType,
) {
val fromCurrencyStatus = quoteModel.fromTokenInfo.cryptoCurrencyStatus
val swapCurrencyStatus = quoteModel.fromTokenInfo.swapCurrencyStatus
val includeFeeInAmount = quoteModel.preparedSwapConfigState.includeFeeInAmount
val amount = quoteModel.fromTokenInfo.tokenAmount
val amountToRequest = if (includeFeeInAmount is IncludeFeeInAmount.Included) {
@ -179,14 +169,14 @@ internal class SwapNotificationsFactory(
}
is TxFeeState.SingleFeeState -> feeState.fee
}
val isCardano = BlockchainUtils.isCardano(fromCurrencyStatus.currency.network.rawId)
val isCardano = BlockchainUtils.isCardano(swapCurrencyStatus.currency.network.rawId)
// blockchain specific
addExistentialWarningNotification(
existentialDeposit = quoteModel.currencyCheck?.existentialDeposit,
feeAmount = fee?.fee?.amount?.value.orZero(),
sendingAmount = amountToRequest.value,
cryptoCurrencyStatus = fromCurrencyStatus,
cryptoCurrencyStatus = swapCurrencyStatus.status,
onReduceClick = { reduceBy, reduceByDiff, _ ->
actions.onReduceByAmount(
// use in swap notification amountToRequest because fee is already subtracted
@ -198,7 +188,7 @@ internal class SwapNotificationsFactory(
addValidateTransactionNotifications(
dustValue = quoteModel.currencyCheck?.dustValue.orZero(),
validationError = quoteModel.validationResult,
cryptoCurrency = fromCurrencyStatus.currency,
cryptoCurrency = swapCurrencyStatus.currency,
minAdaValue = quoteModel.minAdaValue,
onReduceClick = { reduceTo, _ ->
actions.onReduceToAmount(amount.copy(value = reduceTo))
@ -209,26 +199,26 @@ internal class SwapNotificationsFactory(
dustValue = quoteModel.currencyCheck?.dustValue,
feeValue = fee?.fee?.amount?.value.orZero(),
sendingAmount = amountToRequest.value,
cryptoCurrencyStatus = fromCurrencyStatus,
cryptoCurrencyStatus = swapCurrencyStatus.status,
feeCurrencyStatus = feeCryptoCurrencyStatus,
)
}
addReserveAmountErrorNotification(
reserveAmount = quoteModel.currencyCheck?.reserveAmount,
sendingAmount = amountToRequest.value,
cryptoCurrency = fromCurrencyStatus.currency,
cryptoCurrency = swapCurrencyStatus.currency,
feeCryptoCurrency = feeCryptoCurrencyStatus?.currency,
isAccountFunded = true, // consider the account is funded on the provider side
)
addReduceAmountNotification(
cryptoCurrencyStatus = fromCurrencyStatus,
cryptoCurrencyStatus = swapCurrencyStatus.status,
fromAmount = quoteModel.fromTokenInfo.tokenAmount,
onReduceByAmount = actions.onReduceByAmount,
)
addTransactionLimitErrorNotification(
currencyCheck = quoteModel.currencyCheck,
sendingAmount = amountToRequest.value,
cryptoCurrencyStatus = fromCurrencyStatus,
cryptoCurrencyStatus = swapCurrencyStatus.status,
feeCurrencyStatus = feeCryptoCurrencyStatus,
feeValue = fee?.feeValue.orZero(),
onReduceClick = { reduceTo, _ ->
@ -240,11 +230,11 @@ internal class SwapNotificationsFactory(
private fun MutableList<NotificationUM>.maybeAddNeedReserveToCreateAccountWarning(
quoteModel: SwapState.QuotesLoadedState,
) {
val status = quoteModel.toTokenInfo.cryptoCurrencyStatus.value
val status = quoteModel.toTokenInfo.swapCurrencyStatus.status.value
if (status is CryptoCurrencyStatus.NoAccount) {
val amount = quoteModel.toTokenInfo.tokenAmount.value
val amountToCreateAccount = status.amountToCreateAccount
val currencyTo = quoteModel.toTokenInfo.cryptoCurrencyStatus.currency
val currencyTo = quoteModel.toTokenInfo.swapCurrencyStatus.currency
if (amount < amountToCreateAccount) {
add(
SwapNotificationUM.Warning.NeedReserveToCreateAccount(
@ -258,17 +248,13 @@ internal class SwapNotificationsFactory(
private fun MutableList<NotificationUM>.maybeAddPermissionNeededWarning(
quoteModel: SwapState.QuotesLoadedState,
fromToken: CryptoCurrency,
providerName: String,
) {
if (!quoteModel.preparedSwapConfigState.isAllowedToSpend &&
quoteModel.preparedSwapConfigState.feeState is SwapFeeState.Enough &&
quoteModel.permissionState is PermissionDataState.PermissionReadyForRequest
) {
if (quoteModel.permissionState is PermissionDataState.PermissionRequired) {
add(
SwapNotificationUM.Info.PermissionNeeded(
providerName = providerName,
fromTokenSymbol = fromToken.symbol,
fromTokenSymbol = quoteModel.fromTokenInfo.swapCurrencyStatus.currency.symbol,
onApproveClick = actions.openPermissionBottomSheet,
),
)
@ -308,27 +294,28 @@ internal class SwapNotificationsFactory(
private fun MutableList<NotificationUM>.maybeAddUnableCoverFeeWarning(
quoteModel: SwapState.QuotesLoadedState,
fromToken: CryptoCurrency,
feeCryptoCurrencyStatus: CryptoCurrencyStatus?,
hideFee: Boolean,
) {
if (hideFee) return
val fromCurrency = quoteModel.fromTokenInfo.swapCurrencyStatus.currency
val feeEnoughState = quoteModel.preparedSwapConfigState.feeState as? SwapFeeState.NotEnough ?: return
val shouldShowCoverWarning = quoteModel.preparedSwapConfigState.isBalanceEnough &&
quoteModel.permissionState !is PermissionDataState.PermissionLoading &&
feeEnoughState.feeCurrency != fromToken
feeCryptoCurrencyStatus?.currency != fromCurrency
val isNotEnoughFee =
quoteModel.preparedSwapConfigState.includeFeeInAmount is IncludeFeeInAmount.BalanceNotEnough
val isGaslessAvailable = iGaslessFeeSupportedForNetwork(fromToken.network) &&
val isGaslessAvailable = iGaslessFeeSupportedForNetwork(fromCurrency.network) &&
quoteModel.swapProvider.type == ExchangeProviderType.CEX
if (shouldShowCoverWarning && !isGaslessAvailable || isNotEnoughFee) {
add(
SwapNotificationUM.Error.UnableToCoverFeeWarning(
fromToken = fromToken,
feeCurrency = feeEnoughState.feeCurrency,
currencyName = feeEnoughState.currencyName ?: fromToken.network.name,
currencySymbol = feeEnoughState.currencySymbol ?: fromToken.network.currencySymbol,
fromToken = fromCurrency,
feeCurrency = feeCryptoCurrencyStatus?.currency,
currencyName = feeEnoughState.currencyName ?: fromCurrency.network.name,
currencySymbol = feeEnoughState.currencySymbol ?: fromCurrency.network.currencySymbol,
onConfirmClick = actions.onBuyClick,
),
)

View file

@ -1,10 +1,10 @@
package com.tangem.feature.swap.model
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.feature.swap.domain.models.domain.SwapDataModel
import com.tangem.feature.swap.domain.models.domain.SwapPairLeast
import com.tangem.feature.swap.domain.models.domain.SwapProvider
import com.tangem.feature.swap.domain.models.ui.RequestApproveStateData
import com.tangem.feature.swap.domain.models.ui.SwapState
import com.tangem.feature.swap.domain.models.ui.TokensDataStateExpress
import com.tangem.feature.swap.domain.models.ui.TxFee
@ -12,20 +12,24 @@ import java.math.BigDecimal
data class SwapProcessDataState(
// Initial network id
val fromCryptoCurrency: CryptoCurrencyStatus? = null,
val toCryptoCurrency: CryptoCurrencyStatus? = null,
val fromSwapCurrencyStatus: SwapCurrencyStatus? = null,
val toSwapCurrencyStatus: SwapCurrencyStatus? = null,
val feePaidCryptoCurrency: CryptoCurrencyStatus? = null,
val fromAccount: Account? = null,
val toAccount: Account? = null,
// swap info
val pairs: List<SwapPairLeast> = emptyList(),
val selectedPairProviders: List<SwapProvider> = emptyList(),
val selectedProvider: SwapProvider? = null,
val lastLoadedSwapStates: Map<SwapProvider, SwapState> = emptyMap(),
// Amount from input
val amount: String? = null,
val reduceBalanceBy: BigDecimal = BigDecimal.ZERO,
val approveDataModel: RequestApproveStateData? = null,
val swapDataModel: SwapDataModel? = null,
val selectedFee: TxFee.Legacy? = null,
val tokensDataState: TokensDataStateExpress? = null,
val selectedProvider: SwapProvider? = null,
val lastLoadedSwapStates: Map<SwapProvider, SwapState> = emptyMap(),
) {
fun getCurrentLoadedSwapState(): SwapState.QuotesLoadedState? {

View file

@ -4,11 +4,10 @@ import androidx.annotation.DrawableRes
import androidx.compose.runtime.Immutable
import androidx.compose.ui.text.input.TextFieldValue
import com.tangem.common.ui.account.AccountTitleUM
import com.tangem.common.ui.bottomsheet.permission.state.GiveTxPermissionState
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.feature.swap.domain.models.ui.PriceImpact
import com.tangem.feature.swap.models.states.FeeItemState
import com.tangem.feature.swap.models.states.ProviderState
@ -18,14 +17,13 @@ import kotlinx.collections.immutable.persistentListOf
internal data class SwapStateHolder(
val sendCardData: SwapCardState,
val receiveCardData: SwapCardState,
val blockchainId: String, // not the same as networkId, its local id in app
val notifications: ImmutableList<NotificationUM> = persistentListOf(),
val isInsufficientFunds: Boolean,
val changeCardsButtonState: ChangeCardsButtonState,
val providerState: ProviderState,
val fee: FeeItemState = FeeItemState.Empty,
val permissionState: GiveTxPermissionState = GiveTxPermissionState.Empty,
val permissionUM: SwapPermissionUM = SwapPermissionUM.Empty,
val priceImpact: PriceImpact,
val successState: SwapSuccessStateHolder? = null,
@ -37,34 +35,35 @@ internal data class SwapStateHolder(
val onRefresh: () -> Unit,
val onBackClicked: () -> Unit,
val onChangeCardsClicked: () -> Unit,
val onSelectTokenClick: (() -> Unit),
val onSelectTokenClick: ((TokenSelectionDirection) -> Unit),
val onSuccess: (() -> Unit),
val onMaxAmountSelected: (() -> Unit)? = null,
val onShowPermissionBottomSheet: () -> Unit = {},
)
@Immutable
sealed class SwapCardState {
abstract val type: TransactionCardType
data class SwapCardData(
@DrawableRes val networkIconRes: Int?,
val type: TransactionCardType,
override val type: TransactionCardType,
val currencyIconState: CurrencyIconState,
val tokenSymbol: TextReference,
val amountEquivalent: TextReference?,
val token: CryptoCurrencyStatus?,
val coinId: String?,
val amountTextFieldValue: TextFieldValue?,
val tokenIconUrl: String?,
val tokenCurrency: String,
val balance: String,
val isBalanceHidden: Boolean,
val isNotNativeToken: Boolean,
val canSelectAnotherToken: Boolean = false,
) : SwapCardState()
data class Empty(
val type: TransactionCardType,
val amountEquivalent: TextReference?,
override val type: TransactionCardType,
val amountEquivalent: TextReference,
val amountTextFieldValue: TextFieldValue?,
val canSelectAnotherToken: Boolean = false,
) : SwapCardState()
data class Loading(
override val type: TransactionCardType,
) : SwapCardState()
}
@ -79,21 +78,21 @@ data class SwapButton(
@Immutable
sealed interface TransactionCardType {
val accountTitleUM: AccountTitleUM?
val accountTitleUM: AccountTitleUM
val inputError: InputError
data class Inputtable(
val onAmountChanged: ((String) -> Unit),
val onFocusChanged: ((Boolean) -> Unit),
override val inputError: InputError,
override val accountTitleUM: AccountTitleUM?,
override val accountTitleUM: AccountTitleUM,
) : TransactionCardType
data class ReadOnly(
val shouldShowWarning: Boolean = false,
val onWarningClick: (() -> Unit)? = null,
override val inputError: InputError = InputError.Empty,
override val accountTitleUM: AccountTitleUM? = null,
override val accountTitleUM: AccountTitleUM,
) : TransactionCardType
sealed interface InputError {
@ -117,3 +116,13 @@ data class LegalState(
enum class ChangeCardsButtonState {
ENABLED, DISABLED, UPDATE_IN_PROGRESS
}
sealed class SwapPermissionUM {
data class PermissionRequired(
val isResetApproval: Boolean,
val spenderAddress: String,
) : SwapPermissionUM()
object Empty : SwapPermissionUM()
}

View file

@ -0,0 +1,6 @@
package com.tangem.feature.swap.models
internal enum class TokenSelectionDirection {
FROM,
TO,
}

View file

@ -1,33 +1,28 @@
package com.tangem.feature.swap.models
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.ui.TxFee
import java.math.BigDecimal
data class UiActions(
internal data class UiActions(
val onAmountChanged: (String) -> Unit,
val onAmountSelected: (Boolean) -> Unit,
val onSwapClick: () -> Unit,
val onGivePermissionClick: () -> Unit,
val onChangeCardsClicked: () -> Unit,
val onBackClicked: () -> Unit,
val onMaxAmountSelected: () -> Unit,
val onReduceToAmount: (SwapAmount) -> Unit,
val onReduceByAmount: (SwapAmount, reduceBy: BigDecimal) -> Unit,
val openPermissionBottomSheet: () -> Unit,
val onChangeApproveType: (ApproveType) -> Unit,
// region new actions
val onRetryClick: () -> Unit,
val onClickFee: () -> Unit,
val onSelectFeeType: (TxFee.Legacy) -> Unit,
val onProviderClick: (String) -> Unit,
val onProviderSelect: (String) -> Unit,
val onBuyClick: (CryptoCurrency) -> Unit,
val onSelectTokenClick: () -> Unit,
val onBuyClick: () -> Unit,
val onSelectTokenClick: (TokenSelectionDirection) -> Unit,
val onSuccess: () -> Unit,
val onLinkClick: (String) -> Unit,
val onReceiveCardWarningClick: () -> Unit,
val onOpenLearnMoreAboutApproveClick: () -> Unit,
)

View file

@ -60,7 +60,7 @@ internal object SwapNotificationUM {
val currencyName: String,
val currencySymbol: String,
val feeCurrency: CryptoCurrency?,
val onConfirmClick: (CryptoCurrency) -> Unit,
val onConfirmClick: () -> Unit,
) : Error(
title = resourceReference(
R.string.warning_express_not_enough_fee_for_token_tx_title,
@ -74,7 +74,7 @@ internal object SwapNotificationUM {
buttonState = feeCurrency?.let {
NotificationConfig.ButtonsState.SecondaryButtonConfig(
text = resourceReference(R.string.common_buy_currency, wrappedList(currencySymbol)),
onClick = { onConfirmClick(it) },
onClick = onConfirmClick,
)
},
)

View file

@ -0,0 +1,9 @@
package com.tangem.feature.swap.router
import com.tangem.core.decompose.navigation.Route
internal sealed interface SwapRoute : Route {
data object Main : SwapRoute
data object Success : SwapRoute
data class SelectToken(val isFromDirection: Boolean) : SwapRoute
}

View file

@ -1,71 +0,0 @@
package com.tangem.feature.swap.router
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
internal class SwapRouter(
private val router: AppRouter,
) {
var currentScreen by mutableStateOf(SwapNavScreen.Main)
private set
fun openScreen(screen: SwapNavScreen) {
currentScreen = screen
}
fun back() {
if (currentScreen == SwapNavScreen.SelectToken) {
currentScreen = SwapNavScreen.Main
} else {
val selectTokensIndex = router.stack.getSelectTokensRouteIndexOrNull()
/*
* If select token screen is not in stack, then just pop to previous screen.
* Otherwise, pop to previous screen that was before select token screen.
*/
if (currentScreen == SwapNavScreen.Success && selectTokensIndex != null) {
// find previous screen that was before select token
val prevRoute = router.stack.getOrNull(index = selectTokensIndex - 1)
if (prevRoute != null) {
router.popTo(prevRoute)
} else {
router.pop()
}
} else {
router.pop()
}
}
}
fun openTokenDetails(userWalletId: UserWalletId, currency: CryptoCurrency) {
val route = AppRoute.CurrencyDetails(
userWalletId = userWalletId,
currency = currency,
)
if (route in router.stack) {
router.popTo(route)
} else {
router.pop {
router.push(route)
}
}
}
private fun List<AppRoute>.getSelectTokensRouteIndexOrNull(): Int? {
return this
.indexOfFirst { it::class == AppRoute.SwapCrypto::class }
.takeIf { it != -1 }
}
}
enum class SwapNavScreen {
Main, Success, SelectToken
}

View file

@ -9,8 +9,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.testTag
import com.tangem.common.ui.bottomsheet.permission.GiveTxPermissionBottomSheet
import com.tangem.common.ui.bottomsheet.permission.state.GiveTxPermissionBottomSheetConfig
import com.tangem.core.ui.components.appbar.AppBarWithBackButton
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
@ -44,7 +42,7 @@ internal fun SwapScreen(stateHolder: SwapStateHolder, feeSelectorBlockComponent:
feeBlock = if (feeSelectorBlockComponent != null) {
@Composable { modifier: Modifier ->
feeSelectorBlockComponent.Content(
modifier = Modifier
modifier = modifier
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(TangemTheme.colors.background.action),
)
@ -61,7 +59,6 @@ internal fun SwapScreen(stateHolder: SwapStateHolder, feeSelectorBlockComponent:
val config = stateHolder.bottomSheetConfig
when (config.content) {
is GiveTxPermissionBottomSheetConfig -> GiveTxPermissionBottomSheet(config = config)
is ChooseProviderBottomSheetConfig -> ChooseProviderBottomSheet(config = config)
is ChooseFeeBottomSheetConfig -> ChooseFeeBottomSheet(config = config)
}

View file

@ -24,25 +24,20 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.constraintlayout.compose.ConstraintLayout
import com.tangem.common.ui.bottomsheet.permission.state.GiveTxPermissionState
import com.tangem.common.ui.extensions.iconResId
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.ui.components.*
import com.tangem.core.ui.components.notifications.Notification
import com.tangem.core.ui.extensions.orMaskWithStars
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.test.SwapTokenScreenTestTags
import com.tangem.domain.models.network.Network
import com.tangem.feature.swap.domain.models.ui.FeeType
import com.tangem.feature.swap.domain.models.ui.PriceImpact
import com.tangem.feature.swap.models.*
@ -50,6 +45,8 @@ import com.tangem.feature.swap.models.states.FeeItemState
import com.tangem.feature.swap.models.states.ProviderState
import com.tangem.feature.swap.models.states.SwapNotificationUM
import com.tangem.feature.swap.presentation.R
import com.tangem.feature.swap.ui.preview.SwapTransactionCardPreview.receiveCard
import com.tangem.feature.swap.ui.preview.SwapTransactionCardPreview.sendCard
import kotlinx.collections.immutable.persistentListOf
@Suppress("LongMethod")
@ -132,22 +129,22 @@ private fun MainInfo(state: SwapStateHolder) {
) {
val (topCard, bottomCard, button) = createRefs()
val priceImpact = state.priceImpact
TransactionCardData(
TransactionCard(
priceImpact = priceImpact,
swapCardState = state.sendCardData,
modifier = Modifier.constrainAs(topCard) {
top.linkTo(parent.top)
},
onSelectTokenClick = state.onSelectTokenClick,
onSelectTokenClick = { state.onSelectTokenClick(TokenSelectionDirection.FROM) },
)
val marginCard = TangemTheme.dimens.spacing12
TransactionCardData(
TransactionCard(
priceImpact = priceImpact,
swapCardState = state.receiveCardData,
modifier = Modifier.constrainAs(bottomCard) {
top.linkTo(topCard.bottom, margin = marginCard)
},
onSelectTokenClick = state.onSelectTokenClick,
onSelectTokenClick = { state.onSelectTokenClick(TokenSelectionDirection.TO) },
)
val marginButton = TangemTheme.dimens.spacing30
SwapButton(
@ -161,43 +158,6 @@ private fun MainInfo(state: SwapStateHolder) {
}
}
@Composable
private fun TransactionCardData(
priceImpact: PriceImpact,
swapCardState: SwapCardState,
onSelectTokenClick: (() -> Unit)?,
modifier: Modifier = Modifier,
) {
when (swapCardState) {
is SwapCardState.Empty -> {
TransactionCardEmpty(
type = swapCardState.type,
amountEquivalent = swapCardState.amountEquivalent,
textFieldValue = swapCardState.amountTextFieldValue,
onChangeTokenClick = if (swapCardState.canSelectAnotherToken) onSelectTokenClick else null,
modifier = modifier,
)
}
is SwapCardState.SwapCardData -> {
TransactionCard(
type = swapCardState.type,
balance = swapCardState.balance.orMaskWithStars(swapCardState.isBalanceHidden),
textFieldValue = swapCardState.amountTextFieldValue,
amountEquivalent = swapCardState.amountEquivalent,
tokenIconUrl = swapCardState.tokenIconUrl.orEmpty(),
tokenCurrency = swapCardState.tokenCurrency,
priceImpact = priceImpact,
networkIconRes = if (swapCardState.isNotNativeToken) swapCardState.networkIconRes else null,
iconPlaceholder = swapCardState.coinId?.let {
Network.RawID(it).iconResId
},
onChangeTokenClick = if (swapCardState.canSelectAnotherToken) onSelectTokenClick else null,
modifier = modifier,
)
}
}
}
@Composable
private fun ProviderTos(tosState: TosState, modifier: Modifier = Modifier) {
val tos = tosState.tosLink
@ -410,41 +370,6 @@ private fun MainButton(state: SwapStateHolder) {
// region preview
private val sendCard = SwapCardState.SwapCardData(
type = TransactionCardType.Inputtable(
onAmountChanged = {},
onFocusChanged = {},
inputError = TransactionCardType.InputError.Empty,
accountTitleUM = null,
),
amountTextFieldValue = TextFieldValue(),
amountEquivalent = stringReference("1 000 000"),
tokenIconUrl = "",
tokenCurrency = "DAI",
isNotNativeToken = true,
canSelectAnotherToken = false,
balance = "123",
coinId = "",
token = null,
networkIconRes = R.drawable.img_polygon_22,
isBalanceHidden = false,
)
private val receiveCard = SwapCardState.SwapCardData(
type = TransactionCardType.ReadOnly(),
amountTextFieldValue = TextFieldValue(),
amountEquivalent = stringReference("1 000 000"),
tokenIconUrl = "",
tokenCurrency = "DAI",
isNotNativeToken = true,
canSelectAnotherToken = true,
balance = "33333",
coinId = "",
token = null,
networkIconRes = R.drawable.img_polygon_22,
isBalanceHidden = false,
)
private val state = SwapStateHolder(
sendCardData = sendCard,
receiveCardData = receiveCard,
@ -469,14 +394,13 @@ private val state = SwapStateHolder(
onRefresh = {},
onBackClicked = {},
onChangeCardsClicked = {},
permissionState = GiveTxPermissionState.InProgress,
blockchainId = "POLYGON",
permissionUM = SwapPermissionUM.Empty,
providerState = ProviderState.Loading(),
priceImpact = PriceImpact.Empty,
shouldShowMaxAmount = true,
isInsufficientFunds = false,
onSuccess = {},
onSelectTokenClick = {},
onSelectTokenClick = { _ -> },
tosState = TosState(
tosLink = LegalState(
title = stringReference("Terms of Use"),

View file

@ -1,143 +1,99 @@
package com.tangem.feature.swap.ui
import androidx.annotation.DrawableRes
import android.content.res.Configuration
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.TextAutoSize
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.SubcomposeAsyncImage
import coil.request.ImageRequest
import com.tangem.common.ui.account.AccountNameUM
import com.tangem.common.ui.account.AccountTitle
import com.tangem.common.ui.account.AccountTitleUM
import com.tangem.common.ui.account.CryptoPortfolioIconConverter
import com.tangem.core.ui.R
import com.tangem.core.ui.components.*
import com.tangem.core.ui.components.buttons.SecondarySmallButton
import com.tangem.core.ui.components.buttons.SmallButtonConfig
import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition
import com.tangem.core.ui.components.currency.icon.CurrencyIcon
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.test.SwapTokenScreenTestTags
import com.tangem.core.ui.utils.ImageBackgroundContrastChecker
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.feature.swap.domain.models.ui.PriceImpact
import com.tangem.feature.swap.models.SwapCardState
import com.tangem.feature.swap.models.TransactionCardType
import kotlinx.coroutines.launch
import com.tangem.feature.swap.ui.preview.SwapTransactionCardPreview
@Suppress("LongParameterList")
@Composable
fun TransactionCard(
type: TransactionCardType,
balance: String,
tokenIconUrl: String,
tokenCurrency: String,
amountEquivalent: TextReference?,
internal fun TransactionCard(
priceImpact: PriceImpact,
textFieldValue: TextFieldValue?,
swapCardState: SwapCardState,
onSelectTokenClick: () -> Unit,
modifier: Modifier = Modifier,
@DrawableRes iconPlaceholder: Int? = null,
@DrawableRes networkIconRes: Int? = null,
onChangeTokenClick: (() -> Unit)? = null,
) {
val cardTag = when (type) {
val cardTag = when (swapCardState.type) {
is TransactionCardType.Inputtable ->
SwapTokenScreenTestTags.SWAP_CARD
is TransactionCardType.ReadOnly ->
SwapTokenScreenTestTags.RECEIVE_CARD
}
Box(
modifier = modifier
.background(
shape = RoundedCornerShape(TangemTheme.dimens.radius16),
color = TangemTheme.colors.background.primary,
when (swapCardState) {
is SwapCardState.Empty -> {
TransactionCardEmpty(
cardState = swapCardState,
onChangeTokenClick = onSelectTokenClick,
modifier = modifier.testTag(cardTag),
)
.fillMaxSize()
.testTag(cardTag),
) {
Column(
modifier = Modifier
.fillMaxWidth(),
verticalArrangement = Arrangement.Top,
horizontalAlignment = Alignment.Start,
) {
Header(balance = stringResourceSafe(R.string.common_balance, balance), type = type)
Content(
type = type,
amountEquivalent = amountEquivalent,
textFieldValue = textFieldValue,
}
is SwapCardState.SwapCardData -> {
TransactionCardData(
cardState = swapCardState,
priceImpact = priceImpact,
onChangeTokenClick = onSelectTokenClick,
modifier = modifier.testTag(cardTag),
)
}
Box(modifier = Modifier.align(Alignment.BottomEnd)) {
Token(
tokenIconUrl = tokenIconUrl,
tokenCurrency = tokenCurrency,
networkIconRes = networkIconRes,
iconPlaceholder = iconPlaceholder,
is SwapCardState.Loading -> TransactionCardLoading(
modifier = modifier.testTag(cardTag),
)
}
if (onChangeTokenClick != null) {
Box(modifier = Modifier.align(Alignment.CenterEnd)) {
ChangeTokenSelector()
}
Box(
Modifier
.align(Alignment.CenterEnd)
.height(TangemTheme.dimens.size116)
.width(TangemTheme.dimens.size102)
.clickable(
indication = ripple(bounded = false),
interactionSource = remember { MutableInteractionSource() },
) { onChangeTokenClick() },
)
}
}
}
@Composable
fun TransactionCardEmpty(
type: TransactionCardType,
amountEquivalent: TextReference?,
textFieldValue: TextFieldValue?,
private fun TransactionCardData(
cardState: SwapCardState.SwapCardData,
priceImpact: PriceImpact,
modifier: Modifier = Modifier,
onChangeTokenClick: (() -> Unit)? = null,
) {
Box(
modifier = modifier
.background(
shape = RoundedCornerShape(TangemTheme.dimens.radius12),
shape = RoundedCornerShape(TangemTheme.dimens.radius16),
color = TangemTheme.colors.background.primary,
)
.fillMaxSize(),
.fillMaxWidth(),
) {
Column(
modifier = Modifier
@ -146,23 +102,25 @@ fun TransactionCardEmpty(
horizontalAlignment = Alignment.Start,
) {
Header(
balance = stringResourceSafe(id = R.string.swapping_token_not_available),
type = type,
balance = stringResourceSafe(
R.string.common_balance,
cardState.balance,
).orMaskWithStars(cardState.isBalanceHidden),
type = cardState.type,
)
Content(
type = type,
amountEquivalent = amountEquivalent,
textFieldValue = textFieldValue,
priceImpact = PriceImpact.Empty,
type = cardState.type,
amountEquivalent = cardState.amountEquivalent,
textFieldValue = cardState.amountTextFieldValue,
priceImpact = priceImpact,
)
}
Box(modifier = Modifier.align(Alignment.BottomEnd)) {
Token(
tokenIconUrl = "",
tokenCurrency = "",
iconPlaceholder = R.drawable.ic_no_token_44,
currencyIconState = cardState.currencyIconState,
tokenSymbol = cardState.tokenSymbol,
)
}
@ -184,6 +142,140 @@ fun TransactionCardEmpty(
}
}
@Composable
private fun TransactionCardEmpty(
cardState: SwapCardState.Empty,
modifier: Modifier = Modifier,
onChangeTokenClick: () -> Unit,
) {
Column(
modifier = modifier
.background(
shape = RoundedCornerShape(TangemTheme.dimens.radius12),
color = TangemTheme.colors.background.primary,
)
.padding(
top = 12.dp,
start = 12.dp,
end = 12.dp,
bottom = 16.dp,
)
.fillMaxWidth(),
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
AccountTitle(
accountTitleUM = cardState.type.accountTitleUM,
modifier = Modifier.fillMaxWidth(),
)
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = cardState.amountTextFieldValue?.text.orEmpty(),
color = TangemTheme.colors.text.disabled,
style = TangemTheme.typography.h2,
autoSize = TextAutoSize.StepBased(
minFontSize = 16.sp,
maxFontSize = TangemTheme.typography.h2.fontSize,
),
maxLines = 1,
modifier = Modifier.testTag(SwapTokenScreenTestTags.SWAP_TEXT_FIELD),
)
Text(
text = cardState.amountEquivalent.resolveAnnotatedReference(),
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.body2,
modifier = Modifier
.defaultMinSize(minHeight = TangemTheme.dimens.size20)
.testTag(SwapTokenScreenTestTags.SWAP_FIAT_AMOUNT),
)
}
SecondarySmallButton(
config = SmallButtonConfig(
text = resourceReference(R.string.common_choose_token),
icon = TangemButtonIconPosition.End(R.drawable.ic_chevron_24),
onClick = onChangeTokenClick,
),
)
}
}
}
@Composable
private fun TransactionCardLoading(modifier: Modifier = Modifier) {
Column(
modifier = modifier
.background(
shape = RoundedCornerShape(TangemTheme.dimens.radius12),
color = TangemTheme.colors.background.primary,
)
.padding(
top = 12.dp,
start = 12.dp,
end = 12.dp,
bottom = 16.dp,
)
.fillMaxWidth(),
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth(),
) {
TextShimmer(
text = stringResourceSafe(R.string.swapping_to_title),
style = TangemTheme.typography.subtitle2,
)
TextShimmer(
style = TangemTheme.typography.body2,
modifier = Modifier
.testTag(SwapTokenScreenTestTags.BALANCE)
.width(60.dp),
)
}
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
TextShimmer(
style = TangemTheme.typography.h2,
modifier = Modifier
.width(100.dp)
.testTag(SwapTokenScreenTestTags.SWAP_TEXT_FIELD),
)
TextShimmer(
style = TangemTheme.typography.body2,
modifier = Modifier
.defaultMinSize(
minHeight = 20.dp,
minWidth = 40.dp,
)
.testTag(SwapTokenScreenTestTags.SWAP_FIAT_AMOUNT),
)
}
SecondarySmallButton(
config = SmallButtonConfig(
text = resourceReference(R.string.common_choose_token),
icon = TangemButtonIconPosition.End(R.drawable.ic_chevron_24),
isEnabled = false,
onClick = {},
),
)
}
}
}
@Composable
private fun Header(type: TransactionCardType, balance: String, modifier: Modifier = Modifier) {
Row(
@ -376,12 +468,7 @@ private fun Content(
@Suppress("MagicNumber")
@Composable
fun Token(
tokenIconUrl: String,
tokenCurrency: String,
@DrawableRes iconPlaceholder: Int? = null,
@DrawableRes networkIconRes: Int? = null,
) {
fun Token(currencyIconState: CurrencyIconState, tokenSymbol: TextReference) {
Column(
modifier = Modifier
.padding(
@ -392,15 +479,13 @@ fun Token(
verticalArrangement = Arrangement.Bottom,
horizontalAlignment = Alignment.End,
) {
TokenIcon(
tokenIconUrl = tokenIconUrl,
tokenCurrency = tokenCurrency,
iconPlaceholder = iconPlaceholder,
networkIconRes = networkIconRes,
CurrencyIcon(
state = currencyIconState,
modifier = Modifier.padding(end = TangemTheme.dimens.spacing16),
)
SpacerH4()
Text(
text = tokenCurrency,
text = tokenSymbol.resolveReference(),
color = TangemTheme.colors.text.primary1,
maxLines = 1,
style = TangemTheme.typography.subtitle2,
@ -412,89 +497,10 @@ fun Token(
}
}
@Suppress("NullableToStringCall")
@Composable
private fun TokenIcon(
tokenIconUrl: String,
tokenCurrency: String,
@DrawableRes iconPlaceholder: Int? = null,
@DrawableRes networkIconRes: Int? = null,
) {
var iconBackgroundColor by remember { mutableStateOf(Color.Transparent) }
var isBackgroundColorDefined by remember { mutableStateOf(false) }
val itemBackgroundColor = TangemTheme.colors.background.primary.toArgb()
val isDarkTheme = isSystemInDarkTheme()
val coroutineScope = rememberCoroutineScope()
Box(
modifier = Modifier
.padding(end = TangemTheme.dimens.spacing16)
.size(TangemTheme.dimens.size42)
.testTag(SwapTokenScreenTestTags.TOKEN_ICON),
) {
val tokenImageModifier = Modifier
.align(Alignment.BottomStart)
.size(TangemTheme.dimens.size36)
.background(
color = iconBackgroundColor,
shape = TangemTheme.shapes.roundedCorners8,
)
.clip(TangemTheme.shapes.roundedCorners8)
val data = tokenIconUrl.ifEmpty { iconPlaceholder }
val pixelsSize = with(LocalDensity.current) { TangemTheme.dimens.size36.roundToPx() }
SubcomposeAsyncImage(
modifier = tokenImageModifier,
model = ImageRequest.Builder(LocalContext.current)
.data(data)
.size(size = pixelsSize)
.memoryCacheKey(key = data.toString() + pixelsSize)
.crossfade(true)
.allowHardware(false)
.listener(
onSuccess = { _, result ->
if (isDarkTheme) {
coroutineScope.launch {
val color = ImageBackgroundContrastChecker(
drawable = result.drawable,
backgroundColor = itemBackgroundColor,
size = pixelsSize,
).getContrastColor(true)
iconBackgroundColor = color
isBackgroundColorDefined = true
}
}
},
).build(),
loading = { CircleShimmer(modifier = tokenImageModifier) },
contentDescription = tokenCurrency,
)
if (networkIconRes != null) {
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.size(TangemTheme.dimens.size18)
.background(color = TangemTheme.colors.background.primary, shape = CircleShape),
contentAlignment = Alignment.Center,
) {
Image(
modifier = Modifier.padding(all = TangemTheme.dimens.spacing2),
painter = painterResource(id = networkIconRes),
contentDescription = null,
)
}
}
}
}
@Composable
fun ChangeTokenSelector() {
Box(
modifier = Modifier
.fillMaxHeight()
.padding(
top = TangemTheme.dimens.spacing12,
start = TangemTheme.dimens.spacing24,
@ -513,129 +519,29 @@ fun ChangeTokenSelector() {
}
}
// region preview
@Preview(widthDp = 328, heightDp = 116, showBackground = true)
// region Preview
@Composable
private fun Preview_TransactionCard_InLightTheme() {
TangemThemePreview(isDark = false) {
TransactionCardPreview()
}
}
@Preview(widthDp = 328, heightDp = 116, showBackground = true)
@Composable
private fun Preview_TransactionCardWithPriceImpact_InLightTheme() {
TangemThemePreview(isDark = false) {
TransactionCardPreviewWithPriceImpact()
}
}
@Preview(widthDp = 328, heightDp = 116, showBackground = true)
@Composable
private fun Preview_TransactionCardWithoutPriceImpact_InLightTheme() {
TangemThemePreview(isDark = false) {
TransactionCardPreviewWithoutPriceImpact()
}
}
@Preview(widthDp = 328, heightDp = 116, showBackground = true)
@Composable
private fun Preview_TransactionCard_InDarkTheme() {
TangemThemePreview(isDark = false) {
TransactionCardPreview()
}
}
@Preview(widthDp = 328, heightDp = 116, showBackground = true)
@Composable
private fun Preview_TransactionCardWithPriceImpact_InDarkTheme() {
TangemThemePreview(isDark = false) {
TransactionCardPreviewWithPriceImpact()
}
}
@Preview(widthDp = 328, heightDp = 116, showBackground = true)
@Composable
private fun Preview_TransactionCardWithoutPriceImpact_InDarkTheme() {
TangemThemePreview(isDark = false) {
TransactionCardPreviewWithoutPriceImpact()
}
}
@Composable
private fun TransactionCardPreview() {
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun TransactionCard_Preview(@PreviewParameter(PreviewProvider::class) params: SwapCardState) {
TangemThemePreview {
TransactionCard(
type = TransactionCardType.Inputtable(
onAmountChanged = {},
onFocusChanged = {},
inputError = TransactionCardType.InputError.Empty,
accountTitleUM = null,
),
amountEquivalent = stringReference("1 000 000"),
tokenIconUrl = "",
tokenCurrency = "DAI",
networkIconRes = R.drawable.img_polygon_22,
onChangeTokenClick = {},
balance = "123",
textFieldValue = TextFieldValue(),
priceImpact = PriceImpact.Empty,
swapCardState = params,
onSelectTokenClick = {},
modifier = Modifier,
)
}
@Composable
@Suppress("MagicNumber")
private fun TransactionCardPreviewWithPriceImpact() {
TransactionCard(
type = TransactionCardType.ReadOnly(
shouldShowWarning = true,
accountTitleUM = AccountTitleUM.Account(
prefixText = resourceReference(R.string.common_from),
name = AccountNameUM.DefaultMain.value,
icon = CryptoPortfolioIconConverter.convert(CryptoPortfolioIcon.ofDefaultCustomAccount()),
),
),
amountEquivalent = combinedReference(
stringReference("1 000 000 $"),
styledStringReference(
" (-15%)",
{ SpanStyle(color = TangemTheme.colors.text.attention) },
),
),
tokenIconUrl = "",
tokenCurrency = "DAI",
networkIconRes = R.drawable.img_polygon_22,
onChangeTokenClick = {},
balance = "123",
textFieldValue = TextFieldValue("1000000.0000000000000000000000000"),
priceImpact = PriceImpact(
value = 0.15F.toBigDecimal(),
type = PriceImpact.Type.MEDIUM,
amountSignificance = PriceImpact.AmountSignificance.HIGH,
),
)
}
@Composable
@Suppress("MagicNumber")
private fun TransactionCardPreviewWithoutPriceImpact() {
TransactionCard(
type = TransactionCardType.ReadOnly(
accountTitleUM = AccountTitleUM.Account(
prefixText = resourceReference(R.string.common_from),
name = AccountNameUM.DefaultMain.value,
icon = CryptoPortfolioIconConverter.convert(CryptoPortfolioIcon.ofDefaultCustomAccount()),
),
),
amountEquivalent = stringReference("1 000 000"),
tokenIconUrl = "",
tokenCurrency = "DAI",
networkIconRes = R.drawable.img_polygon_22,
onChangeTokenClick = {},
balance = "123",
textFieldValue = TextFieldValue(),
priceImpact = PriceImpact.Empty,
private class PreviewProvider : PreviewParameterProvider<SwapCardState> {
override val values: Sequence<SwapCardState>
get() = sequenceOf(
SwapTransactionCardPreview.sendCard,
SwapTransactionCardPreview.receiveCard,
SwapTransactionCardPreview.emptyReadOnlyCard,
SwapTransactionCardPreview.emptyInputtableCard,
SwapTransactionCardPreview.loadingCard,
)
}
// endregion preview
// endregion

View file

@ -0,0 +1,79 @@
package com.tangem.feature.swap.ui.preview
import androidx.compose.ui.text.input.TextFieldValue
import com.tangem.common.ui.account.AccountNameUM
import com.tangem.common.ui.account.AccountTitleUM
import com.tangem.common.ui.account.CryptoPortfolioIconConverter
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.feature.swap.models.SwapCardState
import com.tangem.feature.swap.models.TransactionCardType
import com.tangem.feature.swap.presentation.R
internal object SwapTransactionCardPreview {
val sendCard = SwapCardState.SwapCardData(
type = TransactionCardType.Inputtable(
onAmountChanged = {},
onFocusChanged = {},
inputError = TransactionCardType.InputError.Empty,
accountTitleUM = AccountTitleUM.Account(
prefixText = stringReference("From"),
name = AccountNameUM.DefaultMain.value,
icon = CryptoPortfolioIconConverter.convert(CryptoPortfolioIcon.ofDefaultCustomAccount()),
),
),
amountTextFieldValue = TextFieldValue(),
amountEquivalent = stringReference("1 000 000"),
currencyIconState = CurrencyIconState.Loading,
tokenSymbol = stringReference("DAI"),
balance = "123",
isBalanceHidden = false,
)
val receiveCard = SwapCardState.SwapCardData(
type = TransactionCardType.ReadOnly(
accountTitleUM = AccountTitleUM.Account(
prefixText = stringReference("To"),
name = AccountNameUM.DefaultMain.value,
icon = CryptoPortfolioIconConverter.convert(CryptoPortfolioIcon.ofDefaultCustomAccount()),
),
),
amountTextFieldValue = TextFieldValue(),
amountEquivalent = stringReference("1 000 000"),
currencyIconState = CurrencyIconState.Loading,
tokenSymbol = stringReference("DAI"),
balance = "33333",
isBalanceHidden = false,
)
val emptyReadOnlyCard = SwapCardState.Empty(
type = TransactionCardType.ReadOnly(
accountTitleUM = AccountTitleUM.Text(title = resourceReference(R.string.swapping_to_title)),
),
amountEquivalent = stringReference("$0.00"),
amountTextFieldValue = null,
)
val emptyInputtableCard = SwapCardState.Empty(
type = TransactionCardType.Inputtable(
onAmountChanged = {},
onFocusChanged = {},
inputError = TransactionCardType.InputError.Empty,
accountTitleUM = AccountTitleUM.Text(title = resourceReference(R.string.swapping_from_title)),
),
amountEquivalent = stringReference("$0.00"),
amountTextFieldValue = null,
)
val loadingCard = SwapCardState.Loading(
type = TransactionCardType.Inputtable(
onAmountChanged = {},
onFocusChanged = {},
inputError = TransactionCardType.InputError.Empty,
accountTitleUM = AccountTitleUM.Text(title = resourceReference(R.string.swapping_to_title)),
),
)
}

View file

@ -0,0 +1,867 @@
package com.tangem.feature.swap
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.account.models.AccountStatusList
import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
import com.tangem.domain.core.lce.Lce
import com.tangem.domain.exchange.RampStateManager
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.TokensGroupType
import com.tangem.domain.models.TokensSortType
import com.tangem.domain.models.TotalFiatBalance
import com.tangem.domain.models.account.*
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.quote.PriceChange
import com.tangem.domain.models.tokenlist.TokenList
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.feature.swap.model.InitialCurrenciesResolver
import com.tangem.features.swap.SwapComponent.Params.CurrencyPosition
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
import java.math.BigDecimal
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class DefaultInitialCurrenciesResolverTest {
private val getUserWalletUseCase = mockk<GetUserWalletUseCase>()
private val singleAccountStatusListSupplier = mockk<SingleAccountStatusListSupplier>()
private val rampStateManager = mockk<RampStateManager>()
private val userWalletId = UserWalletId("0011")
private val userWallet = mockk<UserWallet> {
every { walletId } returns userWalletId
}
private val resolver = InitialCurrenciesResolver(
getUserWalletUseCase = getUserWalletUseCase,
singleAccountStatusListSupplier = singleAccountStatusListSupplier,
rampStateManager = rampStateManager,
)
private var uniqueIndex = 0
@BeforeEach
fun setup() {
coEvery { getUserWalletUseCase(userWalletId) } returns userWallet.right()
}
// region no initial currency
@Test
fun `GIVEN available tokens with balance WHEN no initial currency THEN returns available token with max fiat balance`() =
runTest {
val currency1 = mockCryptoCurrency()
val currency2 = mockCryptoCurrency()
val currency3 = mockCryptoCurrency()
val status1 = createCurrencyStatus(currency1, fiatAmount = BigDecimal("100"))
val status2 = createCurrencyStatus(currency2, fiatAmount = BigDecimal("300"))
val status3 = createCurrencyStatus(currency3, fiatAmount = BigDecimal("500"))
val accountStatus = createCryptoPortfolioAccountStatus(listOf(status1, status2, status3))
setupSupplier(listOf(accountStatus))
setupAvailability(linkedMapOf(currency1 to true, currency2 to true, currency3 to false))
val (from, to) = resolver.invoke(
userWalletId,
initialCryptoCurrency = null,
swapCurrencyPosition = CurrencyPosition.ANY,
isPaymentAccount = false,
)
assertThat(from?.status).isSameInstanceAs(status2)
assertThat(to).isNull()
}
@Test
fun `GIVEN available tokens without balance WHEN no initial currency THEN returns first token from first account`() =
runTest {
val currency1 = mockCryptoCurrency()
val currency2 = mockCryptoCurrency()
val status1 = createCurrencyStatus(currency1, fiatAmount = BigDecimal.ZERO)
val status2 = createCurrencyStatus(currency2, fiatAmount = null)
val accountStatus = createCryptoPortfolioAccountStatus(listOf(status1, status2))
setupSupplier(listOf(accountStatus))
setupAvailability(linkedMapOf(currency1 to true, currency2 to true))
val (from, to) = resolver.invoke(
userWalletId,
initialCryptoCurrency = null,
swapCurrencyPosition = CurrencyPosition.ANY,
isPaymentAccount = false,
)
assertThat(from?.status).isSameInstanceAs(status1)
assertThat(to).isNull()
}
@Test
fun `GIVEN no available tokens with balance WHEN no initial currency THEN returns token with max fiat balance`() =
runTest {
val currency1 = mockCryptoCurrency()
val currency2 = mockCryptoCurrency()
val status1 = createCurrencyStatus(currency1, fiatAmount = BigDecimal("100"))
val status2 = createCurrencyStatus(currency2, fiatAmount = BigDecimal("200"))
val accountStatus = createCryptoPortfolioAccountStatus(listOf(status1, status2))
setupSupplier(listOf(accountStatus))
setupAvailability(linkedMapOf(currency1 to false, currency2 to false))
val (from, to) = resolver.invoke(
userWalletId,
initialCryptoCurrency = null,
swapCurrencyPosition = CurrencyPosition.ANY,
isPaymentAccount = false,
)
assertThat(from?.status).isSameInstanceAs(status2)
assertThat(to).isNull()
}
@Test
fun `GIVEN no available tokens without balance WHEN no initial currency THEN returns first token from first account`() =
runTest {
val currency1 = mockCryptoCurrency()
val currency2 = mockCryptoCurrency()
val status1 = createCurrencyStatus(currency1, fiatAmount = BigDecimal.ZERO)
val status2 = createCurrencyStatus(currency2, fiatAmount = null)
val accountStatus = createCryptoPortfolioAccountStatus(listOf(status1, status2))
setupSupplier(listOf(accountStatus))
setupAvailability(linkedMapOf(currency1 to false, currency2 to false))
val (from, to) = resolver.invoke(
userWalletId,
initialCryptoCurrency = null,
swapCurrencyPosition = CurrencyPosition.ANY,
isPaymentAccount = false,
)
assertThat(from?.status).isSameInstanceAs(status1)
assertThat(to).isNull()
}
@Test
fun `GIVEN empty accounts WHEN no initial currency THEN returns null pair`() = runTest {
setupSupplier(emptyList())
val (from, to) = resolver.invoke(
userWalletId,
initialCryptoCurrency = null,
swapCurrencyPosition = CurrencyPosition.ANY,
isPaymentAccount = false,
)
assertThat(from).isNull()
assertThat(to).isNull()
}
@Test
fun `GIVEN multiple accounts WHEN fallback to first THEN returns first token from first account`() =
runTest {
val currency1 = mockCryptoCurrency()
val currency2 = mockCryptoCurrency()
val status1 = createCurrencyStatus(currency1, fiatAmount = null)
val status2 = createCurrencyStatus(currency2, fiatAmount = null)
val account1Status = createCryptoPortfolioAccountStatus(listOf(status1))
val account2Status = createCryptoPortfolioAccountStatus(listOf(status2))
setupSupplier(listOf(account1Status, account2Status))
setupAvailability(linkedMapOf(currency1 to true))
setupAvailability(linkedMapOf(currency2 to true))
val (from, to) = resolver.invoke(
userWalletId,
initialCryptoCurrency = null,
swapCurrencyPosition = CurrencyPosition.ANY,
isPaymentAccount = false,
)
assertThat(from?.status).isSameInstanceAs(status1)
assertThat(to).isNull()
}
@Test
fun `GIVEN mixed availability and balance across accounts WHEN no initial currency THEN returns best available with balance`() =
runTest {
val currency1 = mockCryptoCurrency()
val currency2 = mockCryptoCurrency()
val currency3 = mockCryptoCurrency()
val status1 = createCurrencyStatus(currency1, fiatAmount = BigDecimal("50"))
val status2 = createCurrencyStatus(currency2, fiatAmount = BigDecimal("200"))
val status3 = createCurrencyStatus(currency3, fiatAmount = BigDecimal("100"))
val account1Status = createCryptoPortfolioAccountStatus(listOf(status1))
val account2Status = createCryptoPortfolioAccountStatus(listOf(status2, status3))
setupSupplier(listOf(account1Status, account2Status))
setupAvailability(linkedMapOf(currency1 to true))
setupAvailability(linkedMapOf(currency2 to false, currency3 to true))
val (from, to) = resolver.invoke(
userWalletId,
initialCryptoCurrency = null,
swapCurrencyPosition = CurrencyPosition.ANY,
isPaymentAccount = false,
)
assertThat(from?.status).isSameInstanceAs(status3)
assertThat(to).isNull()
}
// endregion
// region initial currency tests
@Test
fun `GIVEN initial currency not found WHEN invoke THEN returns null pair`() = runTest {
val initialCurrency = mockCryptoCurrency()
val otherCurrency = mockCryptoCurrency()
val status = createCurrencyStatus(otherCurrency, fiatAmount = BigDecimal("100"))
val accountStatus = createCryptoPortfolioAccountStatus(listOf(status))
setupSupplier(listOf(accountStatus))
setupAvailability(linkedMapOf(otherCurrency to true))
val (from, to) = resolver.invoke(
userWalletId,
initialCryptoCurrency = initialCurrency,
swapCurrencyPosition = CurrencyPosition.ANY,
isPaymentAccount = false,
)
assertThat(from).isNull()
assertThat(to).isNull()
}
@Test
fun `GIVEN initial currency available with balance WHEN invoke THEN returns it as from`() = runTest {
val sharedId = mockk<CryptoCurrency.ID>(relaxed = true)
val initialCurrency = mockCryptoCurrency(id = sharedId)
val accountCurrency = mockCryptoCurrency(id = sharedId)
val status = createCurrencyStatus(accountCurrency, fiatAmount = BigDecimal("100"))
val accountStatus = createCryptoPortfolioAccountStatus(listOf(status))
setupSupplier(listOf(accountStatus))
setupAvailability(linkedMapOf(accountCurrency to true))
val (from, to) = resolver.invoke(
userWalletId,
initialCryptoCurrency = initialCurrency,
swapCurrencyPosition = CurrencyPosition.ANY,
isPaymentAccount = false,
)
assertThat(from?.status).isSameInstanceAs(status)
assertThat(to).isNull()
}
@Test
fun `GIVEN initial currency available without balance WHEN invoke THEN returns it as to and best as from`() =
runTest {
val sharedId = mockk<CryptoCurrency.ID>(relaxed = true)
val initialCurrency = mockCryptoCurrency(id = sharedId)
val accountCurrency = mockCryptoCurrency(id = sharedId)
val otherCurrency = mockCryptoCurrency()
val initialStatus = createCurrencyStatus(accountCurrency, fiatAmount = BigDecimal.ZERO)
val otherStatus = createCurrencyStatus(otherCurrency, fiatAmount = BigDecimal("200"))
val accountStatus = createCryptoPortfolioAccountStatus(listOf(initialStatus, otherStatus))
setupSupplier(listOf(accountStatus))
setupAvailability(linkedMapOf(accountCurrency to true, otherCurrency to true))
val (from, to) = resolver.invoke(
userWalletId,
initialCryptoCurrency = initialCurrency,
swapCurrencyPosition = CurrencyPosition.ANY,
isPaymentAccount = false,
)
assertThat(from?.status).isSameInstanceAs(otherStatus)
assertThat(to?.status).isSameInstanceAs(initialStatus)
}
@Test
fun `GIVEN initial currency available without balance and is only token WHEN invoke THEN returns it as to and from is null`() =
runTest {
val sharedId = mockk<CryptoCurrency.ID>(relaxed = true)
val initialCurrency = mockCryptoCurrency(id = sharedId)
val accountCurrency = mockCryptoCurrency(id = sharedId)
val status = createCurrencyStatus(accountCurrency, fiatAmount = BigDecimal.ZERO)
val accountStatus = createCryptoPortfolioAccountStatus(listOf(status))
setupSupplier(listOf(accountStatus))
setupAvailability(linkedMapOf(accountCurrency to true))
val (from, to) = resolver.invoke(
userWalletId,
initialCryptoCurrency = initialCurrency,
swapCurrencyPosition = CurrencyPosition.ANY,
isPaymentAccount = false,
)
assertThat(from).isNull()
assertThat(to?.status).isSameInstanceAs(status)
}
@Test
fun `GIVEN initial currency not available with balance WHEN invoke THEN returns it as from`() = runTest {
val sharedId = mockk<CryptoCurrency.ID>(relaxed = true)
val initialCurrency = mockCryptoCurrency(id = sharedId)
val accountCurrency = mockCryptoCurrency(id = sharedId)
val status = createCurrencyStatus(accountCurrency, fiatAmount = BigDecimal("100"))
val accountStatus = createCryptoPortfolioAccountStatus(listOf(status))
setupSupplier(listOf(accountStatus))
setupAvailability(linkedMapOf(accountCurrency to false))
val (from, to) = resolver.invoke(
userWalletId,
initialCryptoCurrency = initialCurrency,
swapCurrencyPosition = CurrencyPosition.ANY,
isPaymentAccount = false,
)
assertThat(from?.status).isSameInstanceAs(status)
assertThat(to).isNull()
}
@Test
fun `GIVEN initial currency not available with balance and other available with higher balance WHEN invoke THEN returns initial as from`() =
runTest {
val sharedId = mockk<CryptoCurrency.ID>(relaxed = true)
val initialCurrency = mockCryptoCurrency(id = sharedId)
val accountCurrency = mockCryptoCurrency(id = sharedId)
val otherCurrency = mockCryptoCurrency()
val initialStatus = createCurrencyStatus(accountCurrency, fiatAmount = BigDecimal("100"))
val otherStatus = createCurrencyStatus(otherCurrency, fiatAmount = BigDecimal("500"))
val accountStatus = createCryptoPortfolioAccountStatus(listOf(initialStatus, otherStatus))
setupSupplier(listOf(accountStatus))
setupAvailability(linkedMapOf(accountCurrency to false, otherCurrency to true))
val (from, to) = resolver.invoke(
userWalletId,
initialCryptoCurrency = initialCurrency,
swapCurrencyPosition = CurrencyPosition.ANY,
isPaymentAccount = false,
)
assertThat(from?.status).isSameInstanceAs(initialStatus)
assertThat(to).isNull()
}
@Test
fun `GIVEN initial currency not available without balance and other available with balance WHEN invoke THEN returns best available as from`() =
runTest {
val sharedId = mockk<CryptoCurrency.ID>(relaxed = true)
val initialCurrency = mockCryptoCurrency(id = sharedId)
val accountCurrency = mockCryptoCurrency(id = sharedId)
val other1 = mockCryptoCurrency()
val other2 = mockCryptoCurrency()
val initialStatus = createCurrencyStatus(accountCurrency, fiatAmount = null)
val other1Status = createCurrencyStatus(other1, fiatAmount = BigDecimal("100"))
val other2Status = createCurrencyStatus(other2, fiatAmount = BigDecimal("300"))
val accountStatus = createCryptoPortfolioAccountStatus(
listOf(initialStatus, other1Status, other2Status),
)
setupSupplier(listOf(accountStatus))
setupAvailability(linkedMapOf(accountCurrency to false, other1 to true, other2 to true))
val (from, to) = resolver.invoke(
userWalletId,
initialCryptoCurrency = initialCurrency,
swapCurrencyPosition = CurrencyPosition.ANY,
isPaymentAccount = false,
)
assertThat(from?.status).isSameInstanceAs(other2Status)
assertThat(to?.status).isSameInstanceAs(initialStatus)
}
@Test
fun `GIVEN initial currency not available without balance and other available without balance WHEN invoke THEN returns first token as from`() =
runTest {
val sharedId = mockk<CryptoCurrency.ID>(relaxed = true)
val initialCurrency = mockCryptoCurrency(id = sharedId)
val accountCurrency = mockCryptoCurrency(id = sharedId)
val otherCurrency = mockCryptoCurrency()
val initialStatus = createCurrencyStatus(accountCurrency, fiatAmount = null)
val otherStatus = createCurrencyStatus(otherCurrency, fiatAmount = BigDecimal.ZERO)
val accountStatus = createCryptoPortfolioAccountStatus(listOf(otherStatus, initialStatus))
setupSupplier(listOf(accountStatus))
setupAvailability(linkedMapOf(otherCurrency to true, accountCurrency to false))
val (from, to) = resolver.invoke(
userWalletId,
initialCryptoCurrency = initialCurrency,
swapCurrencyPosition = CurrencyPosition.ANY,
isPaymentAccount = false,
)
assertThat(from?.status).isSameInstanceAs(otherStatus)
assertThat(to?.status).isSameInstanceAs(initialStatus)
}
@Test
fun `GIVEN initial currency not available without balance and no available tokens with balance WHEN invoke THEN returns best by balance as from`() =
runTest {
val sharedId = mockk<CryptoCurrency.ID>(relaxed = true)
val initialCurrency = mockCryptoCurrency(id = sharedId)
val accountCurrency = mockCryptoCurrency(id = sharedId)
val otherCurrency = mockCryptoCurrency()
val initialStatus = createCurrencyStatus(accountCurrency, fiatAmount = null)
val otherStatus = createCurrencyStatus(otherCurrency, fiatAmount = BigDecimal("200"))
val accountStatus = createCryptoPortfolioAccountStatus(listOf(initialStatus, otherStatus))
setupSupplier(listOf(accountStatus))
setupAvailability(linkedMapOf(accountCurrency to false, otherCurrency to false))
val (from, to) = resolver.invoke(
userWalletId,
initialCryptoCurrency = initialCurrency,
swapCurrencyPosition = CurrencyPosition.ANY,
isPaymentAccount = false,
)
assertThat(from?.status).isSameInstanceAs(otherStatus)
assertThat(to?.status).isSameInstanceAs(initialStatus)
}
@Test
fun `GIVEN initial currency not available without balance and no available tokens without balance WHEN invoke THEN returns first token as from`() =
runTest {
val sharedId = mockk<CryptoCurrency.ID>(relaxed = true)
val initialCurrency = mockCryptoCurrency(id = sharedId)
val accountCurrency = mockCryptoCurrency(id = sharedId)
val otherCurrency = mockCryptoCurrency()
val initialStatus = createCurrencyStatus(accountCurrency, fiatAmount = null)
val otherStatus = createCurrencyStatus(otherCurrency, fiatAmount = BigDecimal.ZERO)
val accountStatus = createCryptoPortfolioAccountStatus(listOf(otherStatus, initialStatus))
setupSupplier(listOf(accountStatus))
setupAvailability(linkedMapOf(otherCurrency to false, accountCurrency to false))
val (from, to) = resolver.invoke(
userWalletId,
initialCryptoCurrency = initialCurrency,
swapCurrencyPosition = CurrencyPosition.ANY,
isPaymentAccount = false,
)
assertThat(from?.status).isSameInstanceAs(otherStatus)
assertThat(to?.status).isSameInstanceAs(initialStatus)
}
@Test
fun `GIVEN initial currency not available without balance and is only token WHEN invoke THEN returns it as to and from is null`() =
runTest {
val sharedId = mockk<CryptoCurrency.ID>(relaxed = true)
val initialCurrency = mockCryptoCurrency(id = sharedId)
val accountCurrency = mockCryptoCurrency(id = sharedId)
val status = createCurrencyStatus(accountCurrency, fiatAmount = null)
val accountStatus = createCryptoPortfolioAccountStatus(listOf(status))
setupSupplier(listOf(accountStatus))
setupAvailability(linkedMapOf(accountCurrency to false))
val (from, to) = resolver.invoke(
userWalletId,
initialCryptoCurrency = initialCurrency,
swapCurrencyPosition = CurrencyPosition.ANY,
isPaymentAccount = false,
)
assertThat(from).isNull()
assertThat(to?.status).isSameInstanceAs(status)
}
@Test
fun `GIVEN initial from second account without balance and same token in main with balance WHEN invoke THEN does not pick same token as from`() =
runTest {
val sharedNetworkId = "ethereum"
val sharedContractAddress = "0xUSDT"
// Same token (same network + contract), different ids (simulates different derivations)
val idInSecondary = mockCurrencyId(sharedNetworkId, sharedContractAddress)
val idInMain = mockCurrencyId(sharedNetworkId, sharedContractAddress)
val initialCurrency = mockCryptoCurrency(id = idInSecondary)
val usdtInSecondary = mockCryptoCurrency(id = idInSecondary)
val usdtInMain = mockCryptoCurrency(id = idInMain)
val statusInSecondary = createCurrencyStatus(usdtInSecondary, fiatAmount = null)
val statusInMain = createCurrencyStatus(usdtInMain, fiatAmount = BigDecimal("1000"))
val accountStatus = createCryptoPortfolioAccountStatus(listOf(statusInMain, statusInSecondary))
setupSupplier(listOf(accountStatus))
setupAvailability(linkedMapOf(usdtInMain to true, usdtInSecondary to true))
val (from, to) = resolver.invoke(
userWalletId,
initialCryptoCurrency = initialCurrency,
swapCurrencyPosition = CurrencyPosition.ANY,
isPaymentAccount = false,
)
// Selected goes to TO; FROM must not be the same token from another account
assertThat(from).isNull()
assertThat(to?.status).isSameInstanceAs(statusInSecondary)
}
@Test
fun `GIVEN initial from second account with balance WHEN invoke THEN returns it as from`() = runTest {
val idInSecondary = mockCurrencyId("ethereum", "0xUSDT")
val initialCurrency = mockCryptoCurrency(id = idInSecondary)
val usdtInSecondary = mockCryptoCurrency(id = idInSecondary)
val otherCurrency = mockCryptoCurrency()
val statusInSecondary = createCurrencyStatus(usdtInSecondary, fiatAmount = BigDecimal("200"))
val otherStatus = createCurrencyStatus(otherCurrency, fiatAmount = BigDecimal("500"))
val accountStatus = createCryptoPortfolioAccountStatus(listOf(otherStatus, statusInSecondary))
setupSupplier(listOf(accountStatus))
setupAvailability(linkedMapOf(otherCurrency to true, usdtInSecondary to true))
val (from, to) = resolver.invoke(
userWalletId,
initialCryptoCurrency = initialCurrency,
swapCurrencyPosition = CurrencyPosition.ANY,
isPaymentAccount = false,
)
assertThat(from?.status).isSameInstanceAs(statusInSecondary)
assertThat(to).isNull()
}
@Test
fun `GIVEN initial in secondary account placed in TO WHEN invoke THEN FROM is picked only from same account`() =
runTest {
// Main account has a high-balance available currency.
val mainOnlyCurrency = mockCryptoCurrency()
val mainStatus = createCurrencyStatus(mainOnlyCurrency, fiatAmount = BigDecimal("10000"))
val mainAccount = createCryptoPortfolioAccountStatus(
currencies = listOf(mainStatus),
derivationIndexValue = 0,
)
// Secondary account holds the initial currency (available, zero balance → TO)
// plus another available currency with balance.
val initialId = mockCurrencyId("ethereum", "0xUSDT")
val initialCurrency = mockCryptoCurrency(id = initialId)
val initialInSecondary = mockCryptoCurrency(id = initialId)
val secondaryCompanion = mockCryptoCurrency()
val initialStatus = createCurrencyStatus(initialInSecondary, fiatAmount = BigDecimal.ZERO)
val secondaryStatus = createCurrencyStatus(secondaryCompanion, fiatAmount = BigDecimal("50"))
val secondaryAccount = createCryptoPortfolioAccountStatus(
currencies = listOf(initialStatus, secondaryStatus),
derivationIndexValue = 1,
)
setupSupplier(listOf(mainAccount, secondaryAccount))
setupAvailability(linkedMapOf(mainOnlyCurrency to true))
setupAvailability(linkedMapOf(initialInSecondary to true, secondaryCompanion to true))
val (from, to) = resolver.invoke(
userWalletId,
initialCryptoCurrency = initialCurrency,
swapCurrencyPosition = CurrencyPosition.ANY,
isPaymentAccount = false,
)
// FROM must come from secondary account only — never the main account's high-balance currency.
assertThat(from?.status).isSameInstanceAs(secondaryStatus)
assertThat(to?.status).isSameInstanceAs(initialStatus)
}
@Test
fun `GIVEN initial in secondary account is only token in that account WHEN invoke THEN FROM is null`() =
runTest {
// Main account has candidates that must NOT be picked as FROM.
val mainOnlyCurrency = mockCryptoCurrency()
val mainStatus = createCurrencyStatus(mainOnlyCurrency, fiatAmount = BigDecimal("500"))
val mainAccount = createCryptoPortfolioAccountStatus(
currencies = listOf(mainStatus),
derivationIndexValue = 0,
)
// Secondary account has only the initial currency (available, no balance → TO).
val initialId = mockCurrencyId("ethereum", "0xUSDT")
val initialCurrency = mockCryptoCurrency(id = initialId)
val initialInSecondary = mockCryptoCurrency(id = initialId)
val initialStatus = createCurrencyStatus(initialInSecondary, fiatAmount = BigDecimal.ZERO)
val secondaryAccount = createCryptoPortfolioAccountStatus(
currencies = listOf(initialStatus),
derivationIndexValue = 1,
)
setupSupplier(listOf(mainAccount, secondaryAccount))
setupAvailability(linkedMapOf(mainOnlyCurrency to true))
setupAvailability(linkedMapOf(initialInSecondary to true))
val (from, to) = resolver.invoke(
userWalletId,
initialCryptoCurrency = initialCurrency,
swapCurrencyPosition = CurrencyPosition.ANY,
isPaymentAccount = false,
)
// Secondary account has no other candidates; FROM must be null, not pulled from main.
assertThat(from).isNull()
assertThat(to?.status).isSameInstanceAs(initialStatus)
}
// endregion
// region currency position FROM
@Test
fun `GIVEN position FROM and available with balance WHEN invoke THEN returns selected as from`() = runTest {
val sharedId = mockk<CryptoCurrency.ID>(relaxed = true)
val initialCurrency = mockCryptoCurrency(id = sharedId)
val accountCurrency = mockCryptoCurrency(id = sharedId)
val status = createCurrencyStatus(accountCurrency, fiatAmount = BigDecimal("100"))
val accountStatus = createCryptoPortfolioAccountStatus(listOf(status))
setupSupplier(listOf(accountStatus))
setupAvailability(linkedMapOf(accountCurrency to true))
val (from, to) = resolver.invoke(
userWalletId,
initialCryptoCurrency = initialCurrency,
swapCurrencyPosition = CurrencyPosition.FROM,
isPaymentAccount = false,
)
assertThat(from?.status).isSameInstanceAs(status)
assertThat(to).isNull()
}
@Test
fun `GIVEN position FROM and not available without balance WHEN invoke THEN still returns selected as from`() =
runTest {
val sharedId = mockk<CryptoCurrency.ID>(relaxed = true)
val initialCurrency = mockCryptoCurrency(id = sharedId)
val accountCurrency = mockCryptoCurrency(id = sharedId)
val status = createCurrencyStatus(accountCurrency, fiatAmount = null)
val accountStatus = createCryptoPortfolioAccountStatus(listOf(status))
setupSupplier(listOf(accountStatus))
setupAvailability(linkedMapOf(accountCurrency to false))
val (from, to) = resolver.invoke(
userWalletId,
initialCryptoCurrency = initialCurrency,
swapCurrencyPosition = CurrencyPosition.FROM,
isPaymentAccount = false,
)
assertThat(from?.status).isSameInstanceAs(status)
assertThat(to).isNull()
}
// endregion
// region currency position TO
@Test
fun `GIVEN position TO and available with balance WHEN invoke THEN returns selected as to`() = runTest {
val sharedId = mockk<CryptoCurrency.ID>(relaxed = true)
val initialCurrency = mockCryptoCurrency(id = sharedId)
val accountCurrency = mockCryptoCurrency(id = sharedId)
val status = createCurrencyStatus(accountCurrency, fiatAmount = BigDecimal("100"))
val accountStatus = createCryptoPortfolioAccountStatus(listOf(status))
setupSupplier(listOf(accountStatus))
setupAvailability(linkedMapOf(accountCurrency to true))
val (from, to) = resolver.invoke(
userWalletId,
initialCryptoCurrency = initialCurrency,
swapCurrencyPosition = CurrencyPosition.TO,
isPaymentAccount = false,
)
assertThat(from).isNull()
assertThat(to?.status).isSameInstanceAs(status)
}
@Test
fun `GIVEN position TO and not available without balance WHEN invoke THEN still returns selected as to`() =
runTest {
val sharedId = mockk<CryptoCurrency.ID>(relaxed = true)
val initialCurrency = mockCryptoCurrency(id = sharedId)
val accountCurrency = mockCryptoCurrency(id = sharedId)
val status = createCurrencyStatus(accountCurrency, fiatAmount = null)
val accountStatus = createCryptoPortfolioAccountStatus(listOf(status))
setupSupplier(listOf(accountStatus))
setupAvailability(linkedMapOf(accountCurrency to false))
val (from, to) = resolver.invoke(
userWalletId,
initialCryptoCurrency = initialCurrency,
swapCurrencyPosition = CurrencyPosition.TO,
isPaymentAccount = false,
)
assertThat(from).isNull()
assertThat(to?.status).isSameInstanceAs(status)
}
// endregion
// region helpers
private fun mockCryptoCurrency(
id: CryptoCurrency.ID = mockCurrencyId(),
): CryptoCurrency = mockk(relaxed = true) {
every { this@mockk.id } returns id
}
private fun mockCurrencyId(
rawNetworkId: String = "net-${uniqueIndex++}",
contractAddress: String = "contract-${uniqueIndex++}",
): CryptoCurrency.ID = mockk(relaxed = true) {
every { this@mockk.rawNetworkId } returns rawNetworkId
every { this@mockk.contractAddress } returns contractAddress
}
private fun createCurrencyStatus(
currency: CryptoCurrency,
fiatAmount: BigDecimal?,
): CryptoCurrencyStatus {
val value = mockk<CryptoCurrencyStatus.Value> {
every { this@mockk.fiatAmount } returns fiatAmount
}
return CryptoCurrencyStatus(currency = currency, value = value)
}
private fun createCryptoPortfolioAccountStatus(
currencies: List<CryptoCurrencyStatus>,
): AccountStatus.CryptoPortfolio {
val account = Account.CryptoPortfolio.createMainAccount(userWalletId = userWalletId)
return AccountStatus.CryptoPortfolio(
account = account,
tokenList = TokenList.Ungrouped(
totalFiatBalance = TotalFiatBalance.Loaded(
amount = BigDecimal.ZERO,
source = StatusSource.ACTUAL,
),
sortedBy = TokensSortType.NONE,
currencies = currencies,
),
priceChangeLce = Lce.Content(PriceChange(value = BigDecimal.ZERO, source = StatusSource.ACTUAL)),
)
}
private fun createCryptoPortfolioAccountStatus(
currencies: List<CryptoCurrencyStatus>,
derivationIndexValue: Int,
): AccountStatus.CryptoPortfolio {
val derivationIndex = requireNotNull(DerivationIndex(value = derivationIndexValue).getOrNull()) {
"Invalid derivation index for test: $derivationIndexValue"
}
val accountId = AccountId.forCryptoPortfolio(
userWalletId = userWalletId,
derivationIndex = derivationIndex,
)
val accountName = if (derivationIndex.isMain) {
AccountName.DefaultMain
} else {
requireNotNull(AccountName.Custom(value = "Account $derivationIndexValue").getOrNull()) {
"Invalid account name for test"
}
}
val account = Account.CryptoPortfolio(
accountId = accountId,
accountName = accountName,
icon = CryptoPortfolioIcon.ofMainAccount(userWalletId),
derivationIndex = derivationIndex,
)
return AccountStatus.CryptoPortfolio(
account = account,
tokenList = TokenList.Ungrouped(
totalFiatBalance = TotalFiatBalance.Loaded(
amount = BigDecimal.ZERO,
source = StatusSource.ACTUAL,
),
sortedBy = TokensSortType.NONE,
currencies = currencies,
),
priceChangeLce = Lce.Content(PriceChange(value = BigDecimal.ZERO, source = StatusSource.ACTUAL)),
)
}
private fun setupSupplier(accountStatuses: List<AccountStatus>) {
val accountStatusList = if (accountStatuses.isEmpty()) {
null
} else {
AccountStatusList(
userWalletId = userWalletId,
accountStatuses = accountStatuses,
totalAccounts = accountStatuses.size,
totalArchivedAccounts = 0,
totalFiatBalance = TotalFiatBalance.Loaded(
amount = BigDecimal.ZERO,
source = StatusSource.ACTUAL,
),
sortType = TokensSortType.NONE,
groupType = TokensGroupType.NONE,
)
}
coEvery {
singleAccountStatusListSupplier.getSyncOrNull(any<SingleAccountStatusListProducer.Params>(), any())
} returns accountStatusList
}
private fun setupAvailability(currenciesAvailability: LinkedHashMap<CryptoCurrency, Boolean>) {
val result = currenciesAvailability.map { (currency, available) ->
val reason = if (available) {
ScenarioUnavailabilityReason.None
} else {
ScenarioUnavailabilityReason.Unreachable
}
currency to reason
}.toMap()
coEvery { rampStateManager.availableForSwap(userWalletId, currenciesAvailability.keys.toList()) } returns result
}
// endregion
}

View file

@ -207,9 +207,9 @@ internal class TangemPayCardPageModel @Inject constructor(
bottomSheetNavigation.dismiss()
router.push(
AppRoute.Swap(
currencyFrom = data.currency,
cryptoCurrency = data.currency,
userWalletId = data.walletId,
isInitialReverseOrder = true,
currencyPosition = AppRoute.Swap.CurrencyPosition.TO,
screenSource = AnalyticsParam.ScreensSources.TangemPay.value,
tangemPayInput = AppRoute.Swap.TangemPayInput(
cryptoAmount = data.cryptoBalance,

View file

@ -55,6 +55,7 @@ import com.tangem.features.tokendetails.ExpressTransactionsEventListener
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
import com.tangem.utils.logging.TangemLogger
import com.tangem.utils.transformer.update
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
@ -62,7 +63,6 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import com.tangem.utils.logging.TangemLogger
import javax.inject.Inject
@Suppress("LongParameterList", "LargeClass")
@ -314,10 +314,10 @@ internal class TangemPayDetailsModel @Inject constructor(
) {
router.push(
AppRoute.Swap(
currencyFrom = currency,
cryptoCurrency = currency,
userWalletId = params.userWalletId,
isInitialReverseOrder = false,
screenSource = AnalyticsParam.ScreensSources.TangemPay.value,
currencyPosition = AppRoute.Swap.CurrencyPosition.FROM,
tangemPayInput = AppRoute.Swap.TangemPayInput(
cryptoAmount = currentBalance.availableForWithdrawal,
fiatAmount = currentBalance.availableForWithdrawal,
@ -425,10 +425,10 @@ internal class TangemPayDetailsModel @Inject constructor(
bottomSheetNavigation.dismiss()
router.push(
AppRoute.Swap(
currencyFrom = data.currency,
cryptoCurrency = data.currency,
userWalletId = data.walletId,
isInitialReverseOrder = true,
screenSource = AnalyticsParam.ScreensSources.TangemPay.value,
currencyPosition = AppRoute.Swap.CurrencyPosition.TO,
tangemPayInput = AppRoute.Swap.TangemPayInput(
cryptoAmount = data.cryptoBalance,
fiatAmount = data.fiatBalance,

View file

@ -787,7 +787,7 @@ internal class TokenDetailsModel @Inject constructor(
} else {
appRouter.push(
AppRoute.Swap(
currencyFrom = cryptoCurrency,
cryptoCurrency = cryptoCurrency,
userWalletId = userWalletId,
screenSource = AnalyticsParam.ScreensSources.Token.value,
),
@ -1307,7 +1307,7 @@ internal class TokenDetailsModel @Inject constructor(
TokenAction.Send -> sendCurrency()
TokenAction.Swap -> appRouter.push(
AppRoute.Swap(
currencyFrom = cryptoCurrency,
cryptoCurrency = cryptoCurrency,
userWalletId = userWalletId,
screenSource = AnalyticsParam.ScreensSources.Token.value,
),

View file

@ -3,6 +3,7 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.state.express
import com.tangem.common.ui.expressStatus.state.ExpressTransactionStateInfoUM
import com.tangem.common.ui.expressStatus.state.ExpressTransactionStateUM
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.feature.swap.domain.models.domain.ExchangeStatus
import com.tangem.feature.swap.domain.models.domain.SwapProvider
import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.ExchangeStatusNotification
@ -15,6 +16,7 @@ internal data class ExchangeUM(
val statuses: ImmutableList<ExchangeStatusState>,
val notification: ExchangeStatusNotification?,
val showProviderLink: Boolean,
val fromUserWalletId: UserWalletId,
val fromCryptoCurrency: CryptoCurrency,
val toCryptoCurrency: CryptoCurrency,
val hasLongTime: Boolean,

View file

@ -19,6 +19,7 @@ import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.quote.QuoteStatus
import com.tangem.domain.models.quote.mapData
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.tokens.model.analytics.TokenExchangeAnalyticsEvent
import com.tangem.feature.swap.domain.models.domain.ExchangeStatus
import com.tangem.feature.swap.domain.models.domain.ExchangeStatus.Companion.isFailed
@ -98,6 +99,7 @@ internal class TokenDetailsSwapTransactionsStateConverter(
val showProviderLink = getShowProviderLink(notification, transaction.status)
result.add(
ExchangeUM(
fromUserWalletId = UserWalletId(swapTransaction.fromUserWalletId),
provider = transaction.provider,
statuses = getStatuses(statusModel?.status),
notification = notification,

View file

@ -12,8 +12,10 @@ import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.quote.QuoteStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.quotes.QuotesRepository
import com.tangem.domain.tokens.model.analytics.TokenExchangeAnalyticsEvent
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.feature.swap.domain.SwapTransactionRepository
import com.tangem.feature.swap.domain.api.SwapRepository
import com.tangem.feature.swap.domain.models.domain.*
@ -21,6 +23,7 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.model.ExpressTr
import com.tangem.feature.tokendetails.presentation.tokendetails.state.express.ExchangeUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.TokenDetailsSwapTransactionsStateConverter
import com.tangem.utils.Provider
import com.tangem.utils.logging.TangemLogger
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@ -29,7 +32,6 @@ import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.map
import com.tangem.utils.logging.TangemLogger
@Suppress("LongParameterList")
internal class ExchangeStatusFactory @AssistedInject constructor(
@ -40,6 +42,7 @@ internal class ExchangeStatusFactory @AssistedInject constructor(
private val manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase,
private val swapTransactionStatusStore: SwapTransactionStatusStore,
private val analyticsEventsHandler: AnalyticsEventHandler,
private val getUserWalletUseCase: GetUserWalletUseCase,
@Assisted private val clickIntents: ExpressTransactionsClickIntents,
@Assisted private val appCurrencyProvider: Provider<AppCurrency>,
@Assisted private val currentStateProvider: Provider<ExpressTransactionsBlockState>,
@ -59,6 +62,7 @@ internal class ExchangeStatusFactory @AssistedInject constructor(
operator fun invoke(): Flow<PersistentList<ExchangeUM>> {
return swapTransactionRepository.getTransactions(
userWallet = userWallet,
cryptoCurrencyId = cryptoCurrency.id,
).conflate()
.map { savedTransactions ->
@ -93,7 +97,7 @@ internal class ExchangeStatusFactory @AssistedInject constructor(
return if (swapTx.activeStatus?.isTerminal == true) {
swapTx
} else {
val statusModel = getExchangeStatus(swapTx.info.txId, swapTx.provider)
val statusModel = getExchangeStatus(swapTx.info.txId, swapTx.provider, swapTx.fromUserWalletId)
if (statusModel != null) {
swapTransactionsStateConverter.updateTxStatus(
@ -106,15 +110,24 @@ internal class ExchangeStatusFactory @AssistedInject constructor(
}
}
private suspend fun getExchangeStatus(txId: String, provider: SwapProvider): ExchangeStatusModel? {
return swapRepository.getExchangeStatus(userWallet = userWallet, txId = txId)
private suspend fun getExchangeStatus(
txId: String,
provider: SwapProvider,
fromUserWalletId: UserWalletId,
): ExchangeStatusModel? {
val fromUserWallet = getUserWalletUseCase(fromUserWalletId).getOrNull()
return swapRepository.getExchangeStatus(
userWallet = fromUserWallet,
userWalletId = fromUserWalletId,
txId = txId,
)
.fold(
ifLeft = { null },
ifRight = { statusModel ->
sendStatusUpdateAnalytics(statusModel, provider)
val accountId = getAccountCurrencyStatusUseCase.invokeSync(
userWalletId = userWallet.walletId,
userWalletId = fromUserWalletId,
currency = cryptoCurrency,
)
.map { it.account.accountId }

View file

@ -1,5 +1,6 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.express
import arrow.core.getOrElse
import com.tangem.common.ui.expressStatus.ExpressStatusBottomSheetConfig
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.datasource.local.swap.ExpressAnalyticsStatus
@ -11,8 +12,10 @@ import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.quote.QuoteStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.quotes.QuotesRepository
import com.tangem.domain.tokens.model.analytics.TokenExchangeAnalyticsEvent
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.feature.swap.domain.SwapTransactionRepository
import com.tangem.feature.swap.domain.api.SwapRepository
import com.tangem.feature.swap.domain.models.domain.*
@ -21,6 +24,7 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDeta
import com.tangem.feature.tokendetails.presentation.tokendetails.state.express.ExchangeUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.TokenDetailsSwapTransactionsStateConverter
import com.tangem.utils.Provider
import com.tangem.utils.logging.TangemLogger
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@ -29,7 +33,6 @@ import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.map
import com.tangem.utils.logging.TangemLogger
import kotlin.coroutines.cancellation.CancellationException
@Suppress("LongParameterList")
@ -41,6 +44,7 @@ internal class TokenDetailsExchangeStatusFactory @AssistedInject constructor(
private val manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase,
private val swapTransactionStatusStore: SwapTransactionStatusStore,
private val analyticsEventsHandler: AnalyticsEventHandler,
private val getUserWalletUseCase: GetUserWalletUseCase,
@Assisted private val clickIntents: ExpressTransactionsClickIntents,
@Assisted private val appCurrencyProvider: Provider<AppCurrency>,
@Assisted private val currentStateProvider: Provider<TokenDetailsState>,
@ -94,7 +98,7 @@ internal class TokenDetailsExchangeStatusFactory @AssistedInject constructor(
return if (swapTx.activeStatus?.isTerminal == true) {
swapTx
} else {
val statusModel = getExchangeStatus(swapTx.info.txId, swapTx.provider)
val statusModel = getExchangeStatus(swapTx.info.txId, swapTx.provider, swapTx.fromUserWalletId)
if (statusModel != null) {
swapTransactionsStateConverter.updateTxStatus(
@ -107,15 +111,26 @@ internal class TokenDetailsExchangeStatusFactory @AssistedInject constructor(
}
}
private suspend fun getExchangeStatus(txId: String, provider: SwapProvider): ExchangeStatusModel? {
return swapRepository.getExchangeStatus(userWallet = userWallet, txId = txId)
.fold(
private suspend fun getExchangeStatus(
txId: String,
provider: SwapProvider,
fromUserWalletId: UserWalletId,
): ExchangeStatusModel? {
val fromUserWallet = getUserWalletUseCase(fromUserWalletId).getOrElse { error ->
TangemLogger.e("Couldn't find userWallet: $error")
return null
}
return swapRepository.getExchangeStatus(
userWallet = fromUserWallet,
userWalletId = fromUserWalletId,
txId = txId,
).fold(
ifLeft = { null },
ifRight = { statusModel ->
sendStatusUpdateAnalytics(statusModel, provider)
val accountId = getAccountCurrencyStatusUseCase.invokeSync(
userWalletId = userWallet.walletId,
userWalletId = fromUserWalletId,
currency = cryptoCurrency,
)
.map { it.account.accountId }

View file

@ -9,6 +9,7 @@ import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrency.ID
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType
import com.tangem.feature.swap.domain.models.domain.ExchangeStatus
import com.tangem.feature.swap.domain.models.domain.SwapProvider
@ -23,6 +24,7 @@ class ExpressStatusBottomSheetStateProvider : PreviewParameterProvider<ExpressSt
get() = sequenceOf(
ExpressStatusBottomSheetConfig(
ExchangeUM(
fromUserWalletId = UserWalletId("AnyUserWalletID"),
info = ExpressTransactionStateInfoUM(
title = TextReference.Str("Transaction Status"),
status = ExpressStatusUM(

View file

@ -17,13 +17,13 @@ import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.clipboard.ClipboardManager
import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.haptic.TangemHapticEffect
import com.tangem.core.ui.haptic.VibratorHapticManager
import com.tangem.core.ui.message.DialogMessage
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
import com.tangem.domain.account.status.usecase.IsCryptoCurrencyCouldHideUseCase
import com.tangem.domain.account.status.usecase.ManageCryptoCurrenciesUseCase
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.extenstions.unwrap
@ -44,7 +44,6 @@ import com.tangem.domain.onramp.model.OnrampSource
import com.tangem.domain.promo.GetStoryContentUseCase
import com.tangem.domain.promo.models.StoryContentIds
import com.tangem.domain.staking.model.StakingOption
import com.tangem.domain.account.status.usecase.IsCryptoCurrencyCouldHideUseCase
import com.tangem.domain.tokens.NeedShowYieldSupplyDepositedWarningUseCase
import com.tangem.domain.tokens.SaveViewedTokenReceiveWarningUseCase
import com.tangem.domain.tokens.SaveViewedYieldSupplyWarningUseCase
@ -455,13 +454,10 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor(
override fun onMultiWalletSwapClick(userWalletId: UserWalletId) {
val selectedWallet = stateHolder.getSelectedWallet() as? WalletState.MultiCurrency.Content ?: return
when (val tokenListState = selectedWallet.tokensListState) {
is WalletTokensListState.ContentState.Content -> checkSwapCryptoAvailability(
tokenCount = tokenListState.items.count { it is TokensListItemUM.Token },
)
is WalletTokensListState.ContentState.PortfolioContent -> checkSwapCryptoAvailability(
tokenCount = tokenListState.items.sumOf { it.tokens.count { it is TokensListItemUM.Token } },
)
when (selectedWallet.tokensListState) {
is WalletTokensListState.ContentState.Content,
is WalletTokensListState.ContentState.PortfolioContent,
-> Unit
WalletTokensListState.ContentState.Loading,
WalletTokensListState.ContentState.Locked,
WalletTokensListState.Empty,
@ -470,7 +466,10 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor(
modelScope.launch {
val swapRoute = getSwapRoute(
AppRoute.SwapCrypto(userWalletId = userWalletId),
AppRoute.Swap(
userWalletId = userWalletId,
screenSource = AnalyticsParam.ScreensSources.Main.value,
),
)
onMultiWalletActionClick(
statusFlow = rampStateManager.getExpressInitializationStatus(userWalletId),
@ -660,7 +659,7 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor(
private fun navigateToSwap(cryptoCurrencyStatus: CryptoCurrencyStatus, userWalletId: UserWalletId) {
appRouter.push(
AppRoute.Swap(
currencyFrom = cryptoCurrencyStatus.currency,
cryptoCurrency = cryptoCurrencyStatus.currency,
userWalletId = userWalletId,
screenSource = AnalyticsParam.ScreensSources.LongTap.value,
),
@ -675,11 +674,4 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor(
}
}
}
private fun checkSwapCryptoAvailability(tokenCount: Int) {
if (tokenCount < 2) {
analyticsEventHandler.send(event = MainScreenAnalyticsEvent.ButtonSwap(AnalyticsParam.Status.Error))
uiMessageSender.send(WalletAlertUM.insufficientTokensCountForSwapping())
}
}
}