Updated on 2026-08-14
This commit is contained in:
parent
53b02cfb10
commit
2be7445212
81 changed files with 4444 additions and 3434 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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?,
|
||||
|
|
|
|||
|
|
@ -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?,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>> {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
148
features/swap/CLAUDE.md
Normal 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
|
||||
```
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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?
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -14,7 +14,8 @@ interface SwapTransactionRepository {
|
|||
|
||||
@Suppress("LongParameterList")
|
||||
suspend fun storeTransaction(
|
||||
userWalletId: UserWalletId,
|
||||
fromUserWalletId: UserWalletId,
|
||||
toUserWalletId: UserWalletId,
|
||||
fromCryptoCurrency: CryptoCurrency,
|
||||
toCryptoCurrency: CryptoCurrency,
|
||||
fromAccount: Account?,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
@Singleton
|
||||
fun provideInitialToCurrencyResolver(
|
||||
swapTransactionRepository: SwapTransactionRepository,
|
||||
): InitialToCurrencyResolver {
|
||||
return DefaultInitialToCurrencyResolver(
|
||||
swapTransactionRepository = swapTransactionRepository,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
internal interface SwapDomainBindModule {
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
fun provideSwapInteractor(swapInteractor: SwapInteractorImpl): SwapInteractor
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
package com.tangem.feature.swap.domain.models.domain
|
||||
|
||||
data class NetworkInfo(
|
||||
val name: String,
|
||||
val blockchainId: String,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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? {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package com.tangem.feature.swap.models
|
||||
|
||||
internal enum class TokenSelectionDirection {
|
||||
FROM,
|
||||
TO,
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -1,84 +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
|
||||
}
|
||||
|
||||
when (swapCardState) {
|
||||
is SwapCardState.Empty -> {
|
||||
TransactionCardEmpty(
|
||||
cardState = swapCardState,
|
||||
onChangeTokenClick = onSelectTokenClick,
|
||||
modifier = modifier.testTag(cardTag),
|
||||
)
|
||||
}
|
||||
is SwapCardState.SwapCardData -> {
|
||||
TransactionCardData(
|
||||
cardState = swapCardState,
|
||||
priceImpact = priceImpact,
|
||||
onChangeTokenClick = onSelectTokenClick,
|
||||
modifier = modifier.testTag(cardTag),
|
||||
)
|
||||
}
|
||||
is SwapCardState.Loading -> TransactionCardLoading(
|
||||
modifier = modifier.testTag(cardTag),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TransactionCardData(
|
||||
cardState: SwapCardState.SwapCardData,
|
||||
priceImpact: PriceImpact,
|
||||
modifier: Modifier = Modifier,
|
||||
onChangeTokenClick: (() -> Unit)? = null,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.background(
|
||||
shape = RoundedCornerShape(TangemTheme.dimens.radius16),
|
||||
color = TangemTheme.colors.background.primary,
|
||||
)
|
||||
.fillMaxSize()
|
||||
.testTag(cardTag),
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
|
|
@ -86,22 +101,26 @@ fun TransactionCard(
|
|||
verticalArrangement = Arrangement.Top,
|
||||
horizontalAlignment = Alignment.Start,
|
||||
) {
|
||||
Header(balance = stringResourceSafe(R.string.common_balance, balance), type = type)
|
||||
Header(
|
||||
balance = stringResourceSafe(
|
||||
R.string.common_balance,
|
||||
cardState.balance,
|
||||
).orMaskWithStars(cardState.isBalanceHidden),
|
||||
type = cardState.type,
|
||||
)
|
||||
|
||||
Content(
|
||||
type = type,
|
||||
amountEquivalent = amountEquivalent,
|
||||
textFieldValue = textFieldValue,
|
||||
type = cardState.type,
|
||||
amountEquivalent = cardState.amountEquivalent,
|
||||
textFieldValue = cardState.amountTextFieldValue,
|
||||
priceImpact = priceImpact,
|
||||
)
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.align(Alignment.BottomEnd)) {
|
||||
Token(
|
||||
tokenIconUrl = tokenIconUrl,
|
||||
tokenCurrency = tokenCurrency,
|
||||
networkIconRes = networkIconRes,
|
||||
iconPlaceholder = iconPlaceholder,
|
||||
currencyIconState = cardState.currencyIconState,
|
||||
tokenSymbol = cardState.tokenSymbol,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -124,61 +143,134 @@ fun TransactionCard(
|
|||
}
|
||||
|
||||
@Composable
|
||||
fun TransactionCardEmpty(
|
||||
type: TransactionCardType,
|
||||
amountEquivalent: TextReference?,
|
||||
textFieldValue: TextFieldValue?,
|
||||
private fun TransactionCardEmpty(
|
||||
cardState: SwapCardState.Empty,
|
||||
modifier: Modifier = Modifier,
|
||||
onChangeTokenClick: (() -> Unit)? = null,
|
||||
onChangeTokenClick: () -> Unit,
|
||||
) {
|
||||
Box(
|
||||
Column(
|
||||
modifier = modifier
|
||||
.background(
|
||||
shape = RoundedCornerShape(TangemTheme.dimens.radius12),
|
||||
color = TangemTheme.colors.background.primary,
|
||||
)
|
||||
.fillMaxSize(),
|
||||
.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
|
||||
.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.Top,
|
||||
horizontalAlignment = Alignment.Start,
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Header(
|
||||
balance = stringResourceSafe(id = R.string.swapping_token_not_available),
|
||||
type = type,
|
||||
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),
|
||||
)
|
||||
|
||||
Content(
|
||||
type = type,
|
||||
amountEquivalent = amountEquivalent,
|
||||
textFieldValue = textFieldValue,
|
||||
priceImpact = PriceImpact.Empty,
|
||||
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),
|
||||
)
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.align(Alignment.BottomEnd)) {
|
||||
Token(
|
||||
tokenIconUrl = "",
|
||||
tokenCurrency = "",
|
||||
iconPlaceholder = R.drawable.ic_no_token_44,
|
||||
SecondarySmallButton(
|
||||
config = SmallButtonConfig(
|
||||
text = resourceReference(R.string.common_choose_token),
|
||||
icon = TangemButtonIconPosition.End(R.drawable.ic_chevron_24),
|
||||
onClick = onChangeTokenClick,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
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 = {},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
private class PreviewProvider : PreviewParameterProvider<SwapCardState> {
|
||||
override val values: Sequence<SwapCardState>
|
||||
get() = sequenceOf(
|
||||
SwapTransactionCardPreview.sendCard,
|
||||
SwapTransactionCardPreview.receiveCard,
|
||||
SwapTransactionCardPreview.emptyReadOnlyCard,
|
||||
SwapTransactionCardPreview.emptyInputtableCard,
|
||||
SwapTransactionCardPreview.loadingCard,
|
||||
)
|
||||
}
|
||||
|
||||
@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,
|
||||
)
|
||||
}
|
||||
|
||||
// endregion preview
|
||||
// endregion
|
||||
|
|
@ -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)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue