Updated on 2026-08-14

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,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),
) {
Column(
modifier = Modifier
.fillMaxWidth(),
verticalArrangement = Arrangement.Top,
horizontalAlignment = Alignment.Start,
AccountTitle(
accountTitleUM = cardState.type.accountTitleUM,
modifier = Modifier.fillMaxWidth(),
)
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Header(
balance = stringResourceSafe(id = R.string.swapping_token_not_available),
type = type,
)
Content(
type = type,
amountEquivalent = amountEquivalent,
textFieldValue = textFieldValue,
priceImpact = PriceImpact.Empty,
)
}
Box(modifier = Modifier.align(Alignment.BottomEnd)) {
Token(
tokenIconUrl = "",
tokenCurrency = "",
iconPlaceholder = R.drawable.ic_no_token_44,
)
}
if (onChangeTokenClick != null) {
Box(modifier = Modifier.align(Alignment.CenterEnd)) {
ChangeTokenSelector()
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = cardState.amountTextFieldValue?.text.orEmpty(),
color = TangemTheme.colors.text.disabled,
style = TangemTheme.typography.h2,
autoSize = TextAutoSize.StepBased(
minFontSize = 16.sp,
maxFontSize = TangemTheme.typography.h2.fontSize,
),
maxLines = 1,
modifier = Modifier.testTag(SwapTokenScreenTestTags.SWAP_TEXT_FIELD),
)
Text(
text = cardState.amountEquivalent.resolveAnnotatedReference(),
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.body2,
modifier = Modifier
.defaultMinSize(minHeight = TangemTheme.dimens.size20)
.testTag(SwapTokenScreenTestTags.SWAP_FIAT_AMOUNT),
)
}
Box(
Modifier
.align(Alignment.CenterEnd)
.height(TangemTheme.dimens.size116)
.width(TangemTheme.dimens.size102)
.clickable(
indication = ripple(bounded = false),
interactionSource = remember { MutableInteractionSource() },
) { onChangeTokenClick() },
SecondarySmallButton(
config = SmallButtonConfig(
text = resourceReference(R.string.common_choose_token),
icon = TangemButtonIconPosition.End(R.drawable.ic_chevron_24),
onClick = onChangeTokenClick,
),
)
}
}
}
@Composable
private fun TransactionCardLoading(modifier: Modifier = Modifier) {
Column(
modifier = modifier
.background(
shape = RoundedCornerShape(TangemTheme.dimens.radius12),
color = TangemTheme.colors.background.primary,
)
.padding(
top = 12.dp,
start = 12.dp,
end = 12.dp,
bottom = 16.dp,
)
.fillMaxWidth(),
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth(),
) {
TextShimmer(
text = stringResourceSafe(R.string.swapping_to_title),
style = TangemTheme.typography.subtitle2,
)
TextShimmer(
style = TangemTheme.typography.body2,
modifier = Modifier
.testTag(SwapTokenScreenTestTags.BALANCE)
.width(60.dp),
)
}
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
TextShimmer(
style = TangemTheme.typography.h2,
modifier = Modifier
.width(100.dp)
.testTag(SwapTokenScreenTestTags.SWAP_TEXT_FIELD),
)
TextShimmer(
style = TangemTheme.typography.body2,
modifier = Modifier
.defaultMinSize(
minHeight = 20.dp,
minWidth = 40.dp,
)
.testTag(SwapTokenScreenTestTags.SWAP_FIAT_AMOUNT),
)
}
SecondarySmallButton(
config = SmallButtonConfig(
text = resourceReference(R.string.common_choose_token),
icon = TangemButtonIconPosition.End(R.drawable.ic_chevron_24),
isEnabled = false,
onClick = {},
),
)
}
}
@ -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(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(
priceImpact = PriceImpact.Empty,
swapCardState = params,
onSelectTokenClick = {},
modifier = Modifier,
)
}
}
@Preview(widthDp = 328, heightDp = 116, showBackground = true)
@Composable
private fun Preview_TransactionCardWithPriceImpact_InLightTheme() {
TangemThemePreview(isDark = false) {
TransactionCardPreviewWithPriceImpact()
}
private class PreviewProvider : PreviewParameterProvider<SwapCardState> {
override val values: Sequence<SwapCardState>
get() = sequenceOf(
SwapTransactionCardPreview.sendCard,
SwapTransactionCardPreview.receiveCard,
SwapTransactionCardPreview.emptyReadOnlyCard,
SwapTransactionCardPreview.emptyInputtableCard,
SwapTransactionCardPreview.loadingCard,
)
}
@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() {
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,
)
}
@Composable
@Suppress("MagicNumber")
private fun TransactionCardPreviewWithPriceImpact() {
TransactionCard(
type = TransactionCardType.ReadOnly(
shouldShowWarning = true,
accountTitleUM = AccountTitleUM.Account(
prefixText = resourceReference(R.string.common_from),
name = AccountNameUM.DefaultMain.value,
icon = CryptoPortfolioIconConverter.convert(CryptoPortfolioIcon.ofDefaultCustomAccount()),
),
),
amountEquivalent = combinedReference(
stringReference("1 000 000 $"),
styledStringReference(
" (-15%)",
{ SpanStyle(color = TangemTheme.colors.text.attention) },
),
),
tokenIconUrl = "",
tokenCurrency = "DAI",
networkIconRes = R.drawable.img_polygon_22,
onChangeTokenClick = {},
balance = "123",
textFieldValue = TextFieldValue("1000000.0000000000000000000000000"),
priceImpact = PriceImpact(
value = 0.15F.toBigDecimal(),
type = PriceImpact.Type.MEDIUM,
amountSignificance = PriceImpact.AmountSignificance.HIGH,
),
)
}
@Composable
@Suppress("MagicNumber")
private fun TransactionCardPreviewWithoutPriceImpact() {
TransactionCard(
type = TransactionCardType.ReadOnly(
accountTitleUM = AccountTitleUM.Account(
prefixText = resourceReference(R.string.common_from),
name = AccountNameUM.DefaultMain.value,
icon = CryptoPortfolioIconConverter.convert(CryptoPortfolioIcon.ofDefaultCustomAccount()),
),
),
amountEquivalent = stringReference("1 000 000"),
tokenIconUrl = "",
tokenCurrency = "DAI",
networkIconRes = R.drawable.img_polygon_22,
onChangeTokenClick = {},
balance = "123",
textFieldValue = TextFieldValue(),
priceImpact = PriceImpact.Empty,
)
}
// endregion preview
// endregion

View file

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

View file

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