Updated on 2026-08-14
This commit is contained in:
parent
53b02cfb10
commit
2be7445212
81 changed files with 4444 additions and 3434 deletions
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
package com.tangem.feature.swap.analytics
|
||||
|
||||
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
|
||||
import com.tangem.core.analytics.models.AnalyticsEvent
|
||||
import com.tangem.core.analytics.models.AnalyticsParam.Key.ACCOUNT_DERIVATION_FROM
|
||||
import com.tangem.core.analytics.models.AnalyticsParam.Key.ACCOUNT_DERIVATION_TO
|
||||
|
|
@ -38,11 +37,6 @@ sealed class SwapEvents(
|
|||
|
||||
class SendTokenBalanceClicked : SwapEvents(event = "Send Token Balance Clicked")
|
||||
|
||||
class ChooseTokenScreenOpened(val hasAvailableTokens: Boolean) : SwapEvents(
|
||||
event = "Choose Token Screen Opened",
|
||||
params = mapOf("Available tokens" to if (hasAvailableTokens) "Yes" else "No"),
|
||||
)
|
||||
|
||||
class ChooseTokenScreenResult(
|
||||
val isTokenChosen: Boolean,
|
||||
val token: String? = null,
|
||||
|
|
@ -72,23 +66,6 @@ sealed class SwapEvents(
|
|||
),
|
||||
)
|
||||
|
||||
class ButtonPermissionApproveClicked(
|
||||
val sendToken: String,
|
||||
val receiveToken: String,
|
||||
val approveType: ApproveType,
|
||||
val provider: SwapProvider,
|
||||
) : SwapEvents(
|
||||
event = "Button - Permission Approve",
|
||||
params = mapOf(
|
||||
"Send Token" to sendToken,
|
||||
"Receive Token" to receiveToken,
|
||||
"Type" to if (approveType == ApproveType.LIMITED) "Current Transaction" else "Unlimited",
|
||||
"Provider" to provider.name,
|
||||
),
|
||||
)
|
||||
|
||||
class ButtonPermissionCancelClicked : SwapEvents(event = "Button - Permission Cancel")
|
||||
|
||||
class ButtonSwipeClicked : SwapEvents(event = "Button - Swipe")
|
||||
|
||||
@Suppress("NullableToStringCall", "LongParameterList")
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ interface ChooseTokenBridge : ChooseTokenBridgeLegacy, ChooseTokenBridgeInternal
|
|||
companion object {
|
||||
val SwapFrom = Settings(
|
||||
title = resourceReference(R.string.swapping_from_title),
|
||||
isShowMarketBlock = false,
|
||||
isShowMarketBlock = true,
|
||||
isShowPaymentAccount = true,
|
||||
)
|
||||
val SwapTo = Settings(
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import com.tangem.core.decompose.model.getOrCreateModel
|
|||
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
|
||||
import com.tangem.feature.swap.choosetoken.api.ChooseTokenComponent
|
||||
import com.tangem.feature.swap.choosetoken.impl.model.ChooseTokenModel
|
||||
import com.tangem.feature.swap.choosetoken.impl.ui.ChooseTokenScreen
|
||||
import com.tangem.feature.swap.models.AddToPortfolioRoute
|
||||
import com.tangem.feature.swap.ui.SwapSelectTokenScreen
|
||||
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioComponent
|
||||
|
|
@ -42,10 +43,13 @@ internal class DefaultChooseTokenComponent @AssistedInject constructor(
|
|||
val bottomSheet by bottomSheetSlot.subscribeAsState()
|
||||
stateOld?.let { stateHolder ->
|
||||
SwapSelectTokenScreen(state = stateHolder, onBack = { model.onBackClicked() })
|
||||
bottomSheet.child?.instance?.BottomSheet()
|
||||
// if old shown we should not show new screen
|
||||
return
|
||||
}
|
||||
// todo swap uncomment
|
||||
// val state by model.state.collectAsStateWithLifecycle()
|
||||
// ChooseTokenScreen(state = state)
|
||||
|
||||
val state by model.state.collectAsStateWithLifecycle()
|
||||
ChooseTokenScreen(state = state)
|
||||
bottomSheet.child?.instance?.BottomSheet()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,225 @@
|
|||
package com.tangem.feature.swap.model
|
||||
|
||||
import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer
|
||||
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
|
||||
import com.tangem.domain.exchange.RampStateManager
|
||||
import com.tangem.domain.models.account.AccountStatus
|
||||
import com.tangem.domain.models.account.PaymentAccountStatusValue
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.swap.models.SwapCurrencyStatus
|
||||
import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason
|
||||
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
|
||||
import com.tangem.features.swap.SwapComponent.Params.CurrencyPosition
|
||||
import com.tangem.utils.extensions.orZero
|
||||
import com.tangem.utils.isNullOrZero
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Resolves the initial FROM and TO currencies when the swap screen opens.
|
||||
*
|
||||
* Selection rules when [initialCryptoCurrency][CryptoCurrency] is provided:
|
||||
* - [CurrencyPosition.FROM] — places the currency as FROM, TO is null.
|
||||
* - [CurrencyPosition.TO] — places the currency as TO, FROM is null.
|
||||
* - [CurrencyPosition.ANY] — auto-places based on availability and balance:
|
||||
* - available with balance → FROM.
|
||||
* - available without balance or unavailable without balance → TO,
|
||||
* and the best candidate from the SAME account as the initial currency is selected as FROM
|
||||
* (the search is scoped to that account only, not the whole portfolio).
|
||||
* - unavailable with balance → FROM.
|
||||
*
|
||||
* When no initial currency is provided, selects the best token from crypto portfolio accounts:
|
||||
* 1. If available tokens with balance exist — the available token with the highest fiat balance.
|
||||
* 2. If available tokens exist but none have balance — the first token from the first account.
|
||||
* 3. If no available tokens exist but tokens with balance exist — the token with the highest fiat balance.
|
||||
* 4. If no tokens have balance — the first token from the first account.
|
||||
*/
|
||||
internal class InitialCurrenciesResolver @Inject constructor(
|
||||
private val getUserWalletUseCase: GetUserWalletUseCase,
|
||||
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
|
||||
private val rampStateManager: RampStateManager,
|
||||
) {
|
||||
|
||||
/**
|
||||
* Resolves the initial FROM/TO currency pair for the swap screen.
|
||||
*
|
||||
* @param userWalletId the wallet to resolve currencies for
|
||||
* @param initialCryptoCurrency pre-selected currency, or null to auto-select
|
||||
* @param swapCurrencyPosition preferred position for the initial currency
|
||||
* @return pair of (from, to) [SwapCurrencyStatus]; either or both may be null
|
||||
*/
|
||||
suspend operator fun invoke(
|
||||
userWalletId: UserWalletId,
|
||||
initialCryptoCurrency: CryptoCurrency?,
|
||||
swapCurrencyPosition: CurrencyPosition,
|
||||
isPaymentAccount: Boolean,
|
||||
): Pair<SwapCurrencyStatus?, SwapCurrencyStatus?> {
|
||||
val walletAccountList = getWalletAccountCurrencyStatusList(userWalletId)
|
||||
val cryptoPortfolioAccounts = walletAccountList.filterKeys { accountStatus ->
|
||||
accountStatus is AccountStatus.CryptoPortfolio
|
||||
}.mapKeys { (key, _) -> key as AccountStatus.CryptoPortfolio }
|
||||
val cryptoPaymentAccounts = walletAccountList.filterKeys { accountStatus ->
|
||||
accountStatus is AccountStatus.Payment
|
||||
}
|
||||
|
||||
val cryptoCurrencyList = cryptoPortfolioAccounts.values.flatten()
|
||||
|
||||
return if (initialCryptoCurrency != null) {
|
||||
val selectedSwapCurrencyStatus = if (isPaymentAccount) {
|
||||
cryptoPaymentAccounts
|
||||
} else {
|
||||
cryptoPortfolioAccounts
|
||||
}.firstNotNullOfOrNull { (_, currencyList) ->
|
||||
currencyList.firstOrNull { currencyStatus ->
|
||||
currencyStatus.currency.id == initialCryptoCurrency.id
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedSwapCurrencyStatus == null) {
|
||||
null to null
|
||||
} else {
|
||||
placeSelectedCurrency(
|
||||
selectedSwapCurrencyStatus = selectedSwapCurrencyStatus,
|
||||
swapCurrencyPosition = swapCurrencyPosition,
|
||||
cryptoPortfolioAccountsMap = cryptoPortfolioAccounts,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
selectCryptoCurrency(
|
||||
cryptoPortfolioAccountsMap = cryptoPortfolioAccounts,
|
||||
cryptoCurrencyList = cryptoCurrencyList,
|
||||
) to null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a map of [AccountStatus] to their [SwapCurrencyStatus] lists,
|
||||
* enriching each currency with its swap availability from [RampStateManager].
|
||||
*/
|
||||
private suspend fun getWalletAccountCurrencyStatusList(
|
||||
userWalletId: UserWalletId,
|
||||
): Map<AccountStatus, List<SwapCurrencyStatus>> {
|
||||
val userWallet = getUserWalletUseCase(userWalletId).getOrNull() ?: return emptyMap()
|
||||
|
||||
val walletAccountCurrencyStatuses = singleAccountStatusListSupplier.getSyncOrNull(
|
||||
SingleAccountStatusListProducer.Params(userWalletId),
|
||||
)?.accountStatuses.orEmpty()
|
||||
|
||||
return walletAccountCurrencyStatuses.associateWith { accountStatus ->
|
||||
val currencyStatuses = when (accountStatus) {
|
||||
is AccountStatus.CryptoPortfolio -> accountStatus.flattenCurrencies()
|
||||
is AccountStatus.Payment -> getPaymentAccountCurrencies(accountStatus)
|
||||
}
|
||||
val availabilityStates = rampStateManager.availableForSwap(
|
||||
userWalletId,
|
||||
currencyStatuses.map { it.currency },
|
||||
)
|
||||
currencyStatuses.map { cryptoCurrencyStatus ->
|
||||
SwapCurrencyStatus(
|
||||
userWallet = userWallet,
|
||||
account = accountStatus.account,
|
||||
status = cryptoCurrencyStatus,
|
||||
isAvailableForSwap = availabilityStates[cryptoCurrencyStatus.currency] ==
|
||||
ScenarioUnavailabilityReason.None,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getPaymentAccountCurrencies(accountStatus: AccountStatus.Payment): List<CryptoCurrencyStatus> {
|
||||
val paymentCryptoCurrencyStatus = when (val statusValue = accountStatus.value) {
|
||||
is PaymentAccountStatusValue.Loaded -> statusValue.cryptoCurrencyStatus
|
||||
else -> null
|
||||
}
|
||||
|
||||
return listOfNotNull(paymentCryptoCurrencyStatus)
|
||||
}
|
||||
|
||||
/**
|
||||
* Places the [selectedSwapCurrencyStatus] into the FROM or TO slot based on [swapCurrencyPosition].
|
||||
*
|
||||
* For [CurrencyPosition.ANY], the position is determined by availability and balance:
|
||||
* currencies that are available with balance go to FROM; otherwise, the selected currency
|
||||
* goes to TO and a best-candidate FROM is resolved via [selectCryptoCurrency] — scoped to the
|
||||
* SAME account that the selected currency belongs to, so we never pull a FROM candidate from a
|
||||
* different account in the portfolio.
|
||||
*/
|
||||
private fun placeSelectedCurrency(
|
||||
selectedSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
swapCurrencyPosition: CurrencyPosition,
|
||||
cryptoPortfolioAccountsMap: Map<AccountStatus.CryptoPortfolio, List<SwapCurrencyStatus>>,
|
||||
): Pair<SwapCurrencyStatus?, SwapCurrencyStatus?> {
|
||||
return when (swapCurrencyPosition) {
|
||||
CurrencyPosition.FROM -> {
|
||||
selectedSwapCurrencyStatus to null
|
||||
}
|
||||
CurrencyPosition.TO -> {
|
||||
null to selectedSwapCurrencyStatus
|
||||
}
|
||||
CurrencyPosition.ANY -> {
|
||||
val isAvailable = selectedSwapCurrencyStatus.isAvailableForSwap
|
||||
val hasBalance = !selectedSwapCurrencyStatus.status.value.fiatAmount.isNullOrZero()
|
||||
if (isAvailable && hasBalance) {
|
||||
selectedSwapCurrencyStatus to null
|
||||
} else if (isAvailable || !hasBalance) {
|
||||
val selectedCurrency = selectedSwapCurrencyStatus.currency
|
||||
val selectedAccountId = selectedSwapCurrencyStatus.account.accountId
|
||||
val sameAccountEntry = cryptoPortfolioAccountsMap.entries
|
||||
.firstOrNull { (accountStatus, _) -> accountStatus.account.accountId == selectedAccountId }
|
||||
|
||||
if (sameAccountEntry == null) {
|
||||
null to selectedSwapCurrencyStatus
|
||||
} else {
|
||||
val scopedList = sameAccountEntry.value
|
||||
.filterNot { it.currency.isSameTokenAs(selectedCurrency) }
|
||||
selectCryptoCurrency(
|
||||
cryptoPortfolioAccountsMap = mapOf(sameAccountEntry.key to scopedList),
|
||||
cryptoCurrencyList = scopedList,
|
||||
) to selectedSwapCurrencyStatus
|
||||
}
|
||||
} else {
|
||||
selectedSwapCurrencyStatus to null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether two currencies refer to the same asset on the same network, regardless of the
|
||||
* owning account. Two instances of the same token in different accounts have distinct
|
||||
* [CryptoCurrency.ID] values (their derivation path differs), so id equality is not sufficient
|
||||
* to detect duplicates when auto-picking a FROM candidate.
|
||||
*/
|
||||
private fun CryptoCurrency.isSameTokenAs(other: CryptoCurrency): Boolean {
|
||||
return id.rawNetworkId == other.id.rawNetworkId &&
|
||||
id.contractAddress == other.id.contractAddress
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects the best token from the crypto portfolio when no initial currency is specified.
|
||||
*
|
||||
* Prioritizes available-for-swap tokens. Among the candidates, picks the one with the highest
|
||||
* [fiatAmount][CryptoCurrencyStatus.Value.fiatAmount]. Falls back to the first token from the
|
||||
* first account if no candidate has a positive balance.
|
||||
*/
|
||||
private fun selectCryptoCurrency(
|
||||
cryptoPortfolioAccountsMap: Map<AccountStatus.CryptoPortfolio, List<SwapCurrencyStatus>>,
|
||||
cryptoCurrencyList: List<SwapCurrencyStatus>,
|
||||
): SwapCurrencyStatus? {
|
||||
return if (cryptoCurrencyList.isEmpty()) {
|
||||
null
|
||||
} else {
|
||||
val hasAvailable = cryptoCurrencyList.any { it.isAvailableForSwap }
|
||||
val candidates = if (hasAvailable) {
|
||||
cryptoCurrencyList.filter { it.isAvailableForSwap }
|
||||
} else {
|
||||
cryptoCurrencyList
|
||||
}
|
||||
candidates
|
||||
.filter { !it.status.value.fiatAmount.isNullOrZero() }
|
||||
.maxByOrNull { it.status.value.fiatAmount.orZero() }
|
||||
?: cryptoPortfolioAccountsMap.entries.firstOrNull()?.value?.firstOrNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -37,15 +37,6 @@ internal class SwapNotificationsFactory(
|
|||
private val iGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork,
|
||||
) {
|
||||
|
||||
fun getInitialErrorStateNotifications(code: Int, onRefreshClick: () -> Unit): ImmutableList<NotificationUM> {
|
||||
return persistentListOf(
|
||||
SwapNotificationUM.Warning.ExpressGeneralError(
|
||||
code = code,
|
||||
onConfirmClick = onRefreshClick,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun getGeneralErrorStateNotifications(
|
||||
message: TextReference?,
|
||||
onClick: () -> Unit,
|
||||
|
|
@ -104,7 +95,6 @@ internal class SwapNotificationsFactory(
|
|||
@Suppress("LongParameterList")
|
||||
fun getConfirmationStateNotifications(
|
||||
quoteModel: SwapState.QuotesLoadedState,
|
||||
fromToken: CryptoCurrency,
|
||||
feeCryptoCurrencyStatus: CryptoCurrencyStatus?,
|
||||
selectedFeeType: FeeType,
|
||||
providerName: String,
|
||||
|
|
@ -114,9 +104,9 @@ internal class SwapNotificationsFactory(
|
|||
maybeAddRentExemptionError(quoteModel)
|
||||
maybeAddDomainWarnings(quoteModel, feeCryptoCurrencyStatus, selectedFeeType)
|
||||
maybeAddNeedReserveToCreateAccountWarning(quoteModel)
|
||||
maybeAddPermissionNeededWarning(quoteModel, fromToken, providerName)
|
||||
maybeAddPermissionNeededWarning(quoteModel, providerName)
|
||||
maybeAddNetworkFeeCoverageWarning(quoteModel, selectedFeeType)
|
||||
maybeAddUnableCoverFeeWarning(quoteModel, fromToken, hideFee)
|
||||
maybeAddUnableCoverFeeWarning(quoteModel, feeCryptoCurrencyStatus, hideFee)
|
||||
maybeAddTransactionInProgressWarning(quoteModel)
|
||||
maybeAddPriceImpactNotification(quoteModel.priceImpact)
|
||||
}
|
||||
|
|
@ -135,7 +125,7 @@ internal class SwapNotificationsFactory(
|
|||
if (quoteModel.permissionState is PermissionDataState.PermissionLoading) {
|
||||
add(SwapNotificationUM.Error.ApprovalInProgressWarning)
|
||||
} else if (quoteModel.preparedSwapConfigState.hasOutgoingTransaction) {
|
||||
val fromCurrency = quoteModel.fromTokenInfo.cryptoCurrencyStatus.currency
|
||||
val fromCurrency = quoteModel.fromTokenInfo.swapCurrencyStatus.currency
|
||||
add(
|
||||
SwapNotificationUM.Error.TransactionInProgressWarning(
|
||||
currencySymbol = fromCurrency.network.currencySymbol,
|
||||
|
|
@ -162,7 +152,7 @@ internal class SwapNotificationsFactory(
|
|||
feeCryptoCurrencyStatus: CryptoCurrencyStatus?,
|
||||
selectedFeeType: FeeType,
|
||||
) {
|
||||
val fromCurrencyStatus = quoteModel.fromTokenInfo.cryptoCurrencyStatus
|
||||
val swapCurrencyStatus = quoteModel.fromTokenInfo.swapCurrencyStatus
|
||||
val includeFeeInAmount = quoteModel.preparedSwapConfigState.includeFeeInAmount
|
||||
val amount = quoteModel.fromTokenInfo.tokenAmount
|
||||
val amountToRequest = if (includeFeeInAmount is IncludeFeeInAmount.Included) {
|
||||
|
|
@ -179,14 +169,14 @@ internal class SwapNotificationsFactory(
|
|||
}
|
||||
is TxFeeState.SingleFeeState -> feeState.fee
|
||||
}
|
||||
val isCardano = BlockchainUtils.isCardano(fromCurrencyStatus.currency.network.rawId)
|
||||
val isCardano = BlockchainUtils.isCardano(swapCurrencyStatus.currency.network.rawId)
|
||||
// blockchain specific
|
||||
|
||||
addExistentialWarningNotification(
|
||||
existentialDeposit = quoteModel.currencyCheck?.existentialDeposit,
|
||||
feeAmount = fee?.fee?.amount?.value.orZero(),
|
||||
sendingAmount = amountToRequest.value,
|
||||
cryptoCurrencyStatus = fromCurrencyStatus,
|
||||
cryptoCurrencyStatus = swapCurrencyStatus.status,
|
||||
onReduceClick = { reduceBy, reduceByDiff, _ ->
|
||||
actions.onReduceByAmount(
|
||||
// use in swap notification amountToRequest because fee is already subtracted
|
||||
|
|
@ -198,7 +188,7 @@ internal class SwapNotificationsFactory(
|
|||
addValidateTransactionNotifications(
|
||||
dustValue = quoteModel.currencyCheck?.dustValue.orZero(),
|
||||
validationError = quoteModel.validationResult,
|
||||
cryptoCurrency = fromCurrencyStatus.currency,
|
||||
cryptoCurrency = swapCurrencyStatus.currency,
|
||||
minAdaValue = quoteModel.minAdaValue,
|
||||
onReduceClick = { reduceTo, _ ->
|
||||
actions.onReduceToAmount(amount.copy(value = reduceTo))
|
||||
|
|
@ -209,26 +199,26 @@ internal class SwapNotificationsFactory(
|
|||
dustValue = quoteModel.currencyCheck?.dustValue,
|
||||
feeValue = fee?.fee?.amount?.value.orZero(),
|
||||
sendingAmount = amountToRequest.value,
|
||||
cryptoCurrencyStatus = fromCurrencyStatus,
|
||||
cryptoCurrencyStatus = swapCurrencyStatus.status,
|
||||
feeCurrencyStatus = feeCryptoCurrencyStatus,
|
||||
)
|
||||
}
|
||||
addReserveAmountErrorNotification(
|
||||
reserveAmount = quoteModel.currencyCheck?.reserveAmount,
|
||||
sendingAmount = amountToRequest.value,
|
||||
cryptoCurrency = fromCurrencyStatus.currency,
|
||||
cryptoCurrency = swapCurrencyStatus.currency,
|
||||
feeCryptoCurrency = feeCryptoCurrencyStatus?.currency,
|
||||
isAccountFunded = true, // consider the account is funded on the provider side
|
||||
)
|
||||
addReduceAmountNotification(
|
||||
cryptoCurrencyStatus = fromCurrencyStatus,
|
||||
cryptoCurrencyStatus = swapCurrencyStatus.status,
|
||||
fromAmount = quoteModel.fromTokenInfo.tokenAmount,
|
||||
onReduceByAmount = actions.onReduceByAmount,
|
||||
)
|
||||
addTransactionLimitErrorNotification(
|
||||
currencyCheck = quoteModel.currencyCheck,
|
||||
sendingAmount = amountToRequest.value,
|
||||
cryptoCurrencyStatus = fromCurrencyStatus,
|
||||
cryptoCurrencyStatus = swapCurrencyStatus.status,
|
||||
feeCurrencyStatus = feeCryptoCurrencyStatus,
|
||||
feeValue = fee?.feeValue.orZero(),
|
||||
onReduceClick = { reduceTo, _ ->
|
||||
|
|
@ -240,11 +230,11 @@ internal class SwapNotificationsFactory(
|
|||
private fun MutableList<NotificationUM>.maybeAddNeedReserveToCreateAccountWarning(
|
||||
quoteModel: SwapState.QuotesLoadedState,
|
||||
) {
|
||||
val status = quoteModel.toTokenInfo.cryptoCurrencyStatus.value
|
||||
val status = quoteModel.toTokenInfo.swapCurrencyStatus.status.value
|
||||
if (status is CryptoCurrencyStatus.NoAccount) {
|
||||
val amount = quoteModel.toTokenInfo.tokenAmount.value
|
||||
val amountToCreateAccount = status.amountToCreateAccount
|
||||
val currencyTo = quoteModel.toTokenInfo.cryptoCurrencyStatus.currency
|
||||
val currencyTo = quoteModel.toTokenInfo.swapCurrencyStatus.currency
|
||||
if (amount < amountToCreateAccount) {
|
||||
add(
|
||||
SwapNotificationUM.Warning.NeedReserveToCreateAccount(
|
||||
|
|
@ -258,17 +248,13 @@ internal class SwapNotificationsFactory(
|
|||
|
||||
private fun MutableList<NotificationUM>.maybeAddPermissionNeededWarning(
|
||||
quoteModel: SwapState.QuotesLoadedState,
|
||||
fromToken: CryptoCurrency,
|
||||
providerName: String,
|
||||
) {
|
||||
if (!quoteModel.preparedSwapConfigState.isAllowedToSpend &&
|
||||
quoteModel.preparedSwapConfigState.feeState is SwapFeeState.Enough &&
|
||||
quoteModel.permissionState is PermissionDataState.PermissionReadyForRequest
|
||||
) {
|
||||
if (quoteModel.permissionState is PermissionDataState.PermissionRequired) {
|
||||
add(
|
||||
SwapNotificationUM.Info.PermissionNeeded(
|
||||
providerName = providerName,
|
||||
fromTokenSymbol = fromToken.symbol,
|
||||
fromTokenSymbol = quoteModel.fromTokenInfo.swapCurrencyStatus.currency.symbol,
|
||||
onApproveClick = actions.openPermissionBottomSheet,
|
||||
),
|
||||
)
|
||||
|
|
@ -308,27 +294,28 @@ internal class SwapNotificationsFactory(
|
|||
|
||||
private fun MutableList<NotificationUM>.maybeAddUnableCoverFeeWarning(
|
||||
quoteModel: SwapState.QuotesLoadedState,
|
||||
fromToken: CryptoCurrency,
|
||||
feeCryptoCurrencyStatus: CryptoCurrencyStatus?,
|
||||
hideFee: Boolean,
|
||||
) {
|
||||
if (hideFee) return
|
||||
val fromCurrency = quoteModel.fromTokenInfo.swapCurrencyStatus.currency
|
||||
val feeEnoughState = quoteModel.preparedSwapConfigState.feeState as? SwapFeeState.NotEnough ?: return
|
||||
val shouldShowCoverWarning = quoteModel.preparedSwapConfigState.isBalanceEnough &&
|
||||
quoteModel.permissionState !is PermissionDataState.PermissionLoading &&
|
||||
feeEnoughState.feeCurrency != fromToken
|
||||
feeCryptoCurrencyStatus?.currency != fromCurrency
|
||||
|
||||
val isNotEnoughFee =
|
||||
quoteModel.preparedSwapConfigState.includeFeeInAmount is IncludeFeeInAmount.BalanceNotEnough
|
||||
|
||||
val isGaslessAvailable = iGaslessFeeSupportedForNetwork(fromToken.network) &&
|
||||
val isGaslessAvailable = iGaslessFeeSupportedForNetwork(fromCurrency.network) &&
|
||||
quoteModel.swapProvider.type == ExchangeProviderType.CEX
|
||||
if (shouldShowCoverWarning && !isGaslessAvailable || isNotEnoughFee) {
|
||||
add(
|
||||
SwapNotificationUM.Error.UnableToCoverFeeWarning(
|
||||
fromToken = fromToken,
|
||||
feeCurrency = feeEnoughState.feeCurrency,
|
||||
currencyName = feeEnoughState.currencyName ?: fromToken.network.name,
|
||||
currencySymbol = feeEnoughState.currencySymbol ?: fromToken.network.currencySymbol,
|
||||
fromToken = fromCurrency,
|
||||
feeCurrency = feeCryptoCurrencyStatus?.currency,
|
||||
currencyName = feeEnoughState.currencyName ?: fromCurrency.network.name,
|
||||
currencySymbol = feeEnoughState.currencySymbol ?: fromCurrency.network.currencySymbol,
|
||||
onConfirmClick = actions.onBuyClick,
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
package com.tangem.feature.swap.model
|
||||
|
||||
import com.tangem.domain.models.account.Account
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.swap.models.SwapCurrencyStatus
|
||||
import com.tangem.feature.swap.domain.models.domain.SwapDataModel
|
||||
import com.tangem.feature.swap.domain.models.domain.SwapPairLeast
|
||||
import com.tangem.feature.swap.domain.models.domain.SwapProvider
|
||||
import com.tangem.feature.swap.domain.models.ui.RequestApproveStateData
|
||||
import com.tangem.feature.swap.domain.models.ui.SwapState
|
||||
import com.tangem.feature.swap.domain.models.ui.TokensDataStateExpress
|
||||
import com.tangem.feature.swap.domain.models.ui.TxFee
|
||||
|
|
@ -12,20 +12,24 @@ import java.math.BigDecimal
|
|||
|
||||
data class SwapProcessDataState(
|
||||
// Initial network id
|
||||
val fromCryptoCurrency: CryptoCurrencyStatus? = null,
|
||||
val toCryptoCurrency: CryptoCurrencyStatus? = null,
|
||||
val fromSwapCurrencyStatus: SwapCurrencyStatus? = null,
|
||||
val toSwapCurrencyStatus: SwapCurrencyStatus? = null,
|
||||
|
||||
val feePaidCryptoCurrency: CryptoCurrencyStatus? = null,
|
||||
val fromAccount: Account? = null,
|
||||
val toAccount: Account? = null,
|
||||
|
||||
// swap info
|
||||
val pairs: List<SwapPairLeast> = emptyList(),
|
||||
|
||||
val selectedPairProviders: List<SwapProvider> = emptyList(),
|
||||
val selectedProvider: SwapProvider? = null,
|
||||
val lastLoadedSwapStates: Map<SwapProvider, SwapState> = emptyMap(),
|
||||
|
||||
// Amount from input
|
||||
val amount: String? = null,
|
||||
val reduceBalanceBy: BigDecimal = BigDecimal.ZERO,
|
||||
val approveDataModel: RequestApproveStateData? = null,
|
||||
val swapDataModel: SwapDataModel? = null,
|
||||
val selectedFee: TxFee.Legacy? = null,
|
||||
val tokensDataState: TokensDataStateExpress? = null,
|
||||
val selectedProvider: SwapProvider? = null,
|
||||
val lastLoadedSwapStates: Map<SwapProvider, SwapState> = emptyMap(),
|
||||
) {
|
||||
|
||||
fun getCurrentLoadedSwapState(): SwapState.QuotesLoadedState? {
|
||||
|
|
|
|||
|
|
@ -4,11 +4,10 @@ import androidx.annotation.DrawableRes
|
|||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import com.tangem.common.ui.account.AccountTitleUM
|
||||
import com.tangem.common.ui.bottomsheet.permission.state.GiveTxPermissionState
|
||||
import com.tangem.common.ui.notifications.NotificationUM
|
||||
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
|
||||
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.feature.swap.domain.models.ui.PriceImpact
|
||||
import com.tangem.feature.swap.models.states.FeeItemState
|
||||
import com.tangem.feature.swap.models.states.ProviderState
|
||||
|
|
@ -18,14 +17,13 @@ import kotlinx.collections.immutable.persistentListOf
|
|||
internal data class SwapStateHolder(
|
||||
val sendCardData: SwapCardState,
|
||||
val receiveCardData: SwapCardState,
|
||||
val blockchainId: String, // not the same as networkId, its local id in app
|
||||
val notifications: ImmutableList<NotificationUM> = persistentListOf(),
|
||||
val isInsufficientFunds: Boolean,
|
||||
val changeCardsButtonState: ChangeCardsButtonState,
|
||||
val providerState: ProviderState,
|
||||
|
||||
val fee: FeeItemState = FeeItemState.Empty,
|
||||
val permissionState: GiveTxPermissionState = GiveTxPermissionState.Empty,
|
||||
val permissionUM: SwapPermissionUM = SwapPermissionUM.Empty,
|
||||
val priceImpact: PriceImpact,
|
||||
|
||||
val successState: SwapSuccessStateHolder? = null,
|
||||
|
|
@ -37,34 +35,35 @@ internal data class SwapStateHolder(
|
|||
val onRefresh: () -> Unit,
|
||||
val onBackClicked: () -> Unit,
|
||||
val onChangeCardsClicked: () -> Unit,
|
||||
val onSelectTokenClick: (() -> Unit),
|
||||
val onSelectTokenClick: ((TokenSelectionDirection) -> Unit),
|
||||
val onSuccess: (() -> Unit),
|
||||
val onMaxAmountSelected: (() -> Unit)? = null,
|
||||
val onShowPermissionBottomSheet: () -> Unit = {},
|
||||
)
|
||||
|
||||
@Immutable
|
||||
sealed class SwapCardState {
|
||||
|
||||
abstract val type: TransactionCardType
|
||||
|
||||
data class SwapCardData(
|
||||
@DrawableRes val networkIconRes: Int?,
|
||||
val type: TransactionCardType,
|
||||
override val type: TransactionCardType,
|
||||
val currencyIconState: CurrencyIconState,
|
||||
val tokenSymbol: TextReference,
|
||||
val amountEquivalent: TextReference?,
|
||||
val token: CryptoCurrencyStatus?,
|
||||
val coinId: String?,
|
||||
val amountTextFieldValue: TextFieldValue?,
|
||||
val tokenIconUrl: String?,
|
||||
val tokenCurrency: String,
|
||||
val balance: String,
|
||||
val isBalanceHidden: Boolean,
|
||||
val isNotNativeToken: Boolean,
|
||||
val canSelectAnotherToken: Boolean = false,
|
||||
) : SwapCardState()
|
||||
|
||||
data class Empty(
|
||||
val type: TransactionCardType,
|
||||
val amountEquivalent: TextReference?,
|
||||
override val type: TransactionCardType,
|
||||
val amountEquivalent: TextReference,
|
||||
val amountTextFieldValue: TextFieldValue?,
|
||||
val canSelectAnotherToken: Boolean = false,
|
||||
) : SwapCardState()
|
||||
|
||||
data class Loading(
|
||||
override val type: TransactionCardType,
|
||||
) : SwapCardState()
|
||||
}
|
||||
|
||||
|
|
@ -79,21 +78,21 @@ data class SwapButton(
|
|||
@Immutable
|
||||
sealed interface TransactionCardType {
|
||||
|
||||
val accountTitleUM: AccountTitleUM?
|
||||
val accountTitleUM: AccountTitleUM
|
||||
val inputError: InputError
|
||||
|
||||
data class Inputtable(
|
||||
val onAmountChanged: ((String) -> Unit),
|
||||
val onFocusChanged: ((Boolean) -> Unit),
|
||||
override val inputError: InputError,
|
||||
override val accountTitleUM: AccountTitleUM?,
|
||||
override val accountTitleUM: AccountTitleUM,
|
||||
) : TransactionCardType
|
||||
|
||||
data class ReadOnly(
|
||||
val shouldShowWarning: Boolean = false,
|
||||
val onWarningClick: (() -> Unit)? = null,
|
||||
override val inputError: InputError = InputError.Empty,
|
||||
override val accountTitleUM: AccountTitleUM? = null,
|
||||
override val accountTitleUM: AccountTitleUM,
|
||||
) : TransactionCardType
|
||||
|
||||
sealed interface InputError {
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package com.tangem.feature.swap.models
|
||||
|
||||
internal enum class TokenSelectionDirection {
|
||||
FROM,
|
||||
TO,
|
||||
}
|
||||
|
|
@ -1,33 +1,28 @@
|
|||
package com.tangem.feature.swap.models
|
||||
|
||||
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.feature.swap.domain.models.SwapAmount
|
||||
import com.tangem.feature.swap.domain.models.ui.TxFee
|
||||
import java.math.BigDecimal
|
||||
|
||||
data class UiActions(
|
||||
internal data class UiActions(
|
||||
val onAmountChanged: (String) -> Unit,
|
||||
val onAmountSelected: (Boolean) -> Unit,
|
||||
val onSwapClick: () -> Unit,
|
||||
val onGivePermissionClick: () -> Unit,
|
||||
val onChangeCardsClicked: () -> Unit,
|
||||
val onBackClicked: () -> Unit,
|
||||
val onMaxAmountSelected: () -> Unit,
|
||||
val onReduceToAmount: (SwapAmount) -> Unit,
|
||||
val onReduceByAmount: (SwapAmount, reduceBy: BigDecimal) -> Unit,
|
||||
val openPermissionBottomSheet: () -> Unit,
|
||||
val onChangeApproveType: (ApproveType) -> Unit,
|
||||
// region new actions
|
||||
val onRetryClick: () -> Unit,
|
||||
val onClickFee: () -> Unit,
|
||||
val onSelectFeeType: (TxFee.Legacy) -> Unit,
|
||||
val onProviderClick: (String) -> Unit,
|
||||
val onProviderSelect: (String) -> Unit,
|
||||
val onBuyClick: (CryptoCurrency) -> Unit,
|
||||
val onSelectTokenClick: () -> Unit,
|
||||
val onBuyClick: () -> Unit,
|
||||
val onSelectTokenClick: (TokenSelectionDirection) -> Unit,
|
||||
val onSuccess: () -> Unit,
|
||||
val onLinkClick: (String) -> Unit,
|
||||
val onReceiveCardWarningClick: () -> Unit,
|
||||
val onOpenLearnMoreAboutApproveClick: () -> Unit,
|
||||
)
|
||||
|
|
@ -60,7 +60,7 @@ internal object SwapNotificationUM {
|
|||
val currencyName: String,
|
||||
val currencySymbol: String,
|
||||
val feeCurrency: CryptoCurrency?,
|
||||
val onConfirmClick: (CryptoCurrency) -> Unit,
|
||||
val onConfirmClick: () -> Unit,
|
||||
) : Error(
|
||||
title = resourceReference(
|
||||
R.string.warning_express_not_enough_fee_for_token_tx_title,
|
||||
|
|
@ -74,7 +74,7 @@ internal object SwapNotificationUM {
|
|||
buttonState = feeCurrency?.let {
|
||||
NotificationConfig.ButtonsState.SecondaryButtonConfig(
|
||||
text = resourceReference(R.string.common_buy_currency, wrappedList(currencySymbol)),
|
||||
onClick = { onConfirmClick(it) },
|
||||
onClick = onConfirmClick,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
package com.tangem.feature.swap.router
|
||||
|
||||
import com.tangem.core.decompose.navigation.Route
|
||||
|
||||
internal sealed interface SwapRoute : Route {
|
||||
data object Main : SwapRoute
|
||||
data object Success : SwapRoute
|
||||
data class SelectToken(val isFromDirection: Boolean) : SwapRoute
|
||||
}
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
package com.tangem.feature.swap.router
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.tangem.common.routing.AppRoute
|
||||
import com.tangem.common.routing.AppRouter
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
|
||||
internal class SwapRouter(
|
||||
private val router: AppRouter,
|
||||
) {
|
||||
|
||||
var currentScreen by mutableStateOf(SwapNavScreen.Main)
|
||||
private set
|
||||
|
||||
fun openScreen(screen: SwapNavScreen) {
|
||||
currentScreen = screen
|
||||
}
|
||||
|
||||
fun back() {
|
||||
if (currentScreen == SwapNavScreen.SelectToken) {
|
||||
currentScreen = SwapNavScreen.Main
|
||||
} else {
|
||||
val selectTokensIndex = router.stack.getSelectTokensRouteIndexOrNull()
|
||||
|
||||
/*
|
||||
* If select token screen is not in stack, then just pop to previous screen.
|
||||
* Otherwise, pop to previous screen that was before select token screen.
|
||||
*/
|
||||
if (currentScreen == SwapNavScreen.Success && selectTokensIndex != null) {
|
||||
// find previous screen that was before select token
|
||||
val prevRoute = router.stack.getOrNull(index = selectTokensIndex - 1)
|
||||
|
||||
if (prevRoute != null) {
|
||||
router.popTo(prevRoute)
|
||||
} else {
|
||||
router.pop()
|
||||
}
|
||||
} else {
|
||||
router.pop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun openTokenDetails(userWalletId: UserWalletId, currency: CryptoCurrency) {
|
||||
val route = AppRoute.CurrencyDetails(
|
||||
userWalletId = userWalletId,
|
||||
currency = currency,
|
||||
)
|
||||
|
||||
if (route in router.stack) {
|
||||
router.popTo(route)
|
||||
} else {
|
||||
router.pop {
|
||||
router.push(route)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<AppRoute>.getSelectTokensRouteIndexOrNull(): Int? {
|
||||
return this
|
||||
.indexOfFirst { it::class == AppRoute.SwapCrypto::class }
|
||||
.takeIf { it != -1 }
|
||||
}
|
||||
}
|
||||
|
||||
enum class SwapNavScreen {
|
||||
Main, Success, SelectToken
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -9,8 +9,6 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import com.tangem.common.ui.bottomsheet.permission.GiveTxPermissionBottomSheet
|
||||
import com.tangem.common.ui.bottomsheet.permission.state.GiveTxPermissionBottomSheetConfig
|
||||
import com.tangem.core.ui.components.appbar.AppBarWithBackButton
|
||||
import com.tangem.core.ui.extensions.stringResourceSafe
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
|
|
@ -44,7 +42,7 @@ internal fun SwapScreen(stateHolder: SwapStateHolder, feeSelectorBlockComponent:
|
|||
feeBlock = if (feeSelectorBlockComponent != null) {
|
||||
@Composable { modifier: Modifier ->
|
||||
feeSelectorBlockComponent.Content(
|
||||
modifier = Modifier
|
||||
modifier = modifier
|
||||
.clip(TangemTheme.shapes.roundedCornersXMedium)
|
||||
.background(TangemTheme.colors.background.action),
|
||||
)
|
||||
|
|
@ -61,7 +59,6 @@ internal fun SwapScreen(stateHolder: SwapStateHolder, feeSelectorBlockComponent:
|
|||
val config = stateHolder.bottomSheetConfig
|
||||
|
||||
when (config.content) {
|
||||
is GiveTxPermissionBottomSheetConfig -> GiveTxPermissionBottomSheet(config = config)
|
||||
is ChooseProviderBottomSheetConfig -> ChooseProviderBottomSheet(config = config)
|
||||
is ChooseFeeBottomSheetConfig -> ChooseFeeBottomSheet(config = config)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,25 +24,20 @@ import androidx.compose.ui.res.painterResource
|
|||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.constraintlayout.compose.ConstraintLayout
|
||||
import com.tangem.common.ui.bottomsheet.permission.state.GiveTxPermissionState
|
||||
import com.tangem.common.ui.extensions.iconResId
|
||||
import com.tangem.common.ui.notifications.NotificationUM
|
||||
import com.tangem.core.ui.components.*
|
||||
import com.tangem.core.ui.components.notifications.Notification
|
||||
import com.tangem.core.ui.extensions.orMaskWithStars
|
||||
import com.tangem.core.ui.extensions.resolveReference
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.core.ui.extensions.stringResourceSafe
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreview
|
||||
import com.tangem.core.ui.test.SwapTokenScreenTestTags
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.feature.swap.domain.models.ui.FeeType
|
||||
import com.tangem.feature.swap.domain.models.ui.PriceImpact
|
||||
import com.tangem.feature.swap.models.*
|
||||
|
|
@ -50,6 +45,8 @@ import com.tangem.feature.swap.models.states.FeeItemState
|
|||
import com.tangem.feature.swap.models.states.ProviderState
|
||||
import com.tangem.feature.swap.models.states.SwapNotificationUM
|
||||
import com.tangem.feature.swap.presentation.R
|
||||
import com.tangem.feature.swap.ui.preview.SwapTransactionCardPreview.receiveCard
|
||||
import com.tangem.feature.swap.ui.preview.SwapTransactionCardPreview.sendCard
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
@Suppress("LongMethod")
|
||||
|
|
@ -132,22 +129,22 @@ private fun MainInfo(state: SwapStateHolder) {
|
|||
) {
|
||||
val (topCard, bottomCard, button) = createRefs()
|
||||
val priceImpact = state.priceImpact
|
||||
TransactionCardData(
|
||||
TransactionCard(
|
||||
priceImpact = priceImpact,
|
||||
swapCardState = state.sendCardData,
|
||||
modifier = Modifier.constrainAs(topCard) {
|
||||
top.linkTo(parent.top)
|
||||
},
|
||||
onSelectTokenClick = state.onSelectTokenClick,
|
||||
onSelectTokenClick = { state.onSelectTokenClick(TokenSelectionDirection.FROM) },
|
||||
)
|
||||
val marginCard = TangemTheme.dimens.spacing12
|
||||
TransactionCardData(
|
||||
TransactionCard(
|
||||
priceImpact = priceImpact,
|
||||
swapCardState = state.receiveCardData,
|
||||
modifier = Modifier.constrainAs(bottomCard) {
|
||||
top.linkTo(topCard.bottom, margin = marginCard)
|
||||
},
|
||||
onSelectTokenClick = state.onSelectTokenClick,
|
||||
onSelectTokenClick = { state.onSelectTokenClick(TokenSelectionDirection.TO) },
|
||||
)
|
||||
val marginButton = TangemTheme.dimens.spacing30
|
||||
SwapButton(
|
||||
|
|
@ -161,43 +158,6 @@ private fun MainInfo(state: SwapStateHolder) {
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TransactionCardData(
|
||||
priceImpact: PriceImpact,
|
||||
swapCardState: SwapCardState,
|
||||
onSelectTokenClick: (() -> Unit)?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
when (swapCardState) {
|
||||
is SwapCardState.Empty -> {
|
||||
TransactionCardEmpty(
|
||||
type = swapCardState.type,
|
||||
amountEquivalent = swapCardState.amountEquivalent,
|
||||
textFieldValue = swapCardState.amountTextFieldValue,
|
||||
onChangeTokenClick = if (swapCardState.canSelectAnotherToken) onSelectTokenClick else null,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
is SwapCardState.SwapCardData -> {
|
||||
TransactionCard(
|
||||
type = swapCardState.type,
|
||||
balance = swapCardState.balance.orMaskWithStars(swapCardState.isBalanceHidden),
|
||||
textFieldValue = swapCardState.amountTextFieldValue,
|
||||
amountEquivalent = swapCardState.amountEquivalent,
|
||||
tokenIconUrl = swapCardState.tokenIconUrl.orEmpty(),
|
||||
tokenCurrency = swapCardState.tokenCurrency,
|
||||
priceImpact = priceImpact,
|
||||
networkIconRes = if (swapCardState.isNotNativeToken) swapCardState.networkIconRes else null,
|
||||
iconPlaceholder = swapCardState.coinId?.let {
|
||||
Network.RawID(it).iconResId
|
||||
},
|
||||
onChangeTokenClick = if (swapCardState.canSelectAnotherToken) onSelectTokenClick else null,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProviderTos(tosState: TosState, modifier: Modifier = Modifier) {
|
||||
val tos = tosState.tosLink
|
||||
|
|
@ -410,41 +370,6 @@ private fun MainButton(state: SwapStateHolder) {
|
|||
|
||||
// region preview
|
||||
|
||||
private val sendCard = SwapCardState.SwapCardData(
|
||||
type = TransactionCardType.Inputtable(
|
||||
onAmountChanged = {},
|
||||
onFocusChanged = {},
|
||||
inputError = TransactionCardType.InputError.Empty,
|
||||
accountTitleUM = null,
|
||||
),
|
||||
amountTextFieldValue = TextFieldValue(),
|
||||
amountEquivalent = stringReference("1 000 000"),
|
||||
tokenIconUrl = "",
|
||||
tokenCurrency = "DAI",
|
||||
isNotNativeToken = true,
|
||||
canSelectAnotherToken = false,
|
||||
balance = "123",
|
||||
coinId = "",
|
||||
token = null,
|
||||
networkIconRes = R.drawable.img_polygon_22,
|
||||
isBalanceHidden = false,
|
||||
)
|
||||
|
||||
private val receiveCard = SwapCardState.SwapCardData(
|
||||
type = TransactionCardType.ReadOnly(),
|
||||
amountTextFieldValue = TextFieldValue(),
|
||||
amountEquivalent = stringReference("1 000 000"),
|
||||
tokenIconUrl = "",
|
||||
tokenCurrency = "DAI",
|
||||
isNotNativeToken = true,
|
||||
canSelectAnotherToken = true,
|
||||
balance = "33333",
|
||||
coinId = "",
|
||||
token = null,
|
||||
networkIconRes = R.drawable.img_polygon_22,
|
||||
isBalanceHidden = false,
|
||||
)
|
||||
|
||||
private val state = SwapStateHolder(
|
||||
sendCardData = sendCard,
|
||||
receiveCardData = receiveCard,
|
||||
|
|
@ -469,14 +394,13 @@ private val state = SwapStateHolder(
|
|||
onRefresh = {},
|
||||
onBackClicked = {},
|
||||
onChangeCardsClicked = {},
|
||||
permissionState = GiveTxPermissionState.InProgress,
|
||||
blockchainId = "POLYGON",
|
||||
permissionUM = SwapPermissionUM.Empty,
|
||||
providerState = ProviderState.Loading(),
|
||||
priceImpact = PriceImpact.Empty,
|
||||
shouldShowMaxAmount = true,
|
||||
isInsufficientFunds = false,
|
||||
onSuccess = {},
|
||||
onSelectTokenClick = {},
|
||||
onSelectTokenClick = { _ -> },
|
||||
tosState = TosState(
|
||||
tosLink = LegalState(
|
||||
title = stringReference("Terms of Use"),
|
||||
|
|
|
|||
|
|
@ -1,84 +1,99 @@
|
|||
package com.tangem.feature.swap.ui
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.TextAutoSize
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.ripple
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.SubcomposeAsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import com.tangem.common.ui.account.AccountNameUM
|
||||
import com.tangem.common.ui.account.AccountTitle
|
||||
import com.tangem.common.ui.account.AccountTitleUM
|
||||
import com.tangem.common.ui.account.CryptoPortfolioIconConverter
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.components.*
|
||||
import com.tangem.core.ui.components.buttons.SecondarySmallButton
|
||||
import com.tangem.core.ui.components.buttons.SmallButtonConfig
|
||||
import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition
|
||||
import com.tangem.core.ui.components.currency.icon.CurrencyIcon
|
||||
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
|
||||
import com.tangem.core.ui.extensions.*
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreview
|
||||
import com.tangem.core.ui.test.SwapTokenScreenTestTags
|
||||
import com.tangem.core.ui.utils.ImageBackgroundContrastChecker
|
||||
import com.tangem.domain.models.account.CryptoPortfolioIcon
|
||||
import com.tangem.feature.swap.domain.models.ui.PriceImpact
|
||||
import com.tangem.feature.swap.models.SwapCardState
|
||||
import com.tangem.feature.swap.models.TransactionCardType
|
||||
import kotlinx.coroutines.launch
|
||||
import com.tangem.feature.swap.ui.preview.SwapTransactionCardPreview
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
@Composable
|
||||
fun TransactionCard(
|
||||
type: TransactionCardType,
|
||||
balance: String,
|
||||
tokenIconUrl: String,
|
||||
tokenCurrency: String,
|
||||
amountEquivalent: TextReference?,
|
||||
internal fun TransactionCard(
|
||||
priceImpact: PriceImpact,
|
||||
textFieldValue: TextFieldValue?,
|
||||
swapCardState: SwapCardState,
|
||||
onSelectTokenClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
@DrawableRes iconPlaceholder: Int? = null,
|
||||
@DrawableRes networkIconRes: Int? = null,
|
||||
onChangeTokenClick: (() -> Unit)? = null,
|
||||
) {
|
||||
val cardTag = when (type) {
|
||||
val cardTag = when (swapCardState.type) {
|
||||
is TransactionCardType.Inputtable ->
|
||||
SwapTokenScreenTestTags.SWAP_CARD
|
||||
is TransactionCardType.ReadOnly ->
|
||||
SwapTokenScreenTestTags.RECEIVE_CARD
|
||||
}
|
||||
|
||||
when (swapCardState) {
|
||||
is SwapCardState.Empty -> {
|
||||
TransactionCardEmpty(
|
||||
cardState = swapCardState,
|
||||
onChangeTokenClick = onSelectTokenClick,
|
||||
modifier = modifier.testTag(cardTag),
|
||||
)
|
||||
}
|
||||
is SwapCardState.SwapCardData -> {
|
||||
TransactionCardData(
|
||||
cardState = swapCardState,
|
||||
priceImpact = priceImpact,
|
||||
onChangeTokenClick = onSelectTokenClick,
|
||||
modifier = modifier.testTag(cardTag),
|
||||
)
|
||||
}
|
||||
is SwapCardState.Loading -> TransactionCardLoading(
|
||||
modifier = modifier.testTag(cardTag),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TransactionCardData(
|
||||
cardState: SwapCardState.SwapCardData,
|
||||
priceImpact: PriceImpact,
|
||||
modifier: Modifier = Modifier,
|
||||
onChangeTokenClick: (() -> Unit)? = null,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.background(
|
||||
shape = RoundedCornerShape(TangemTheme.dimens.radius16),
|
||||
color = TangemTheme.colors.background.primary,
|
||||
)
|
||||
.fillMaxSize()
|
||||
.testTag(cardTag),
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
|
|
@ -86,22 +101,26 @@ fun TransactionCard(
|
|||
verticalArrangement = Arrangement.Top,
|
||||
horizontalAlignment = Alignment.Start,
|
||||
) {
|
||||
Header(balance = stringResourceSafe(R.string.common_balance, balance), type = type)
|
||||
Header(
|
||||
balance = stringResourceSafe(
|
||||
R.string.common_balance,
|
||||
cardState.balance,
|
||||
).orMaskWithStars(cardState.isBalanceHidden),
|
||||
type = cardState.type,
|
||||
)
|
||||
|
||||
Content(
|
||||
type = type,
|
||||
amountEquivalent = amountEquivalent,
|
||||
textFieldValue = textFieldValue,
|
||||
type = cardState.type,
|
||||
amountEquivalent = cardState.amountEquivalent,
|
||||
textFieldValue = cardState.amountTextFieldValue,
|
||||
priceImpact = priceImpact,
|
||||
)
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.align(Alignment.BottomEnd)) {
|
||||
Token(
|
||||
tokenIconUrl = tokenIconUrl,
|
||||
tokenCurrency = tokenCurrency,
|
||||
networkIconRes = networkIconRes,
|
||||
iconPlaceholder = iconPlaceholder,
|
||||
currencyIconState = cardState.currencyIconState,
|
||||
tokenSymbol = cardState.tokenSymbol,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -124,61 +143,134 @@ fun TransactionCard(
|
|||
}
|
||||
|
||||
@Composable
|
||||
fun TransactionCardEmpty(
|
||||
type: TransactionCardType,
|
||||
amountEquivalent: TextReference?,
|
||||
textFieldValue: TextFieldValue?,
|
||||
private fun TransactionCardEmpty(
|
||||
cardState: SwapCardState.Empty,
|
||||
modifier: Modifier = Modifier,
|
||||
onChangeTokenClick: (() -> Unit)? = null,
|
||||
onChangeTokenClick: () -> Unit,
|
||||
) {
|
||||
Box(
|
||||
Column(
|
||||
modifier = modifier
|
||||
.background(
|
||||
shape = RoundedCornerShape(TangemTheme.dimens.radius12),
|
||||
color = TangemTheme.colors.background.primary,
|
||||
)
|
||||
.fillMaxSize(),
|
||||
.padding(
|
||||
top = 12.dp,
|
||||
start = 12.dp,
|
||||
end = 12.dp,
|
||||
bottom = 16.dp,
|
||||
)
|
||||
.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
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
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
package com.tangem.feature.swap.ui.preview
|
||||
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import com.tangem.common.ui.account.AccountNameUM
|
||||
import com.tangem.common.ui.account.AccountTitleUM
|
||||
import com.tangem.common.ui.account.CryptoPortfolioIconConverter
|
||||
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.domain.models.account.CryptoPortfolioIcon
|
||||
import com.tangem.feature.swap.models.SwapCardState
|
||||
import com.tangem.feature.swap.models.TransactionCardType
|
||||
import com.tangem.feature.swap.presentation.R
|
||||
|
||||
internal object SwapTransactionCardPreview {
|
||||
|
||||
val sendCard = SwapCardState.SwapCardData(
|
||||
type = TransactionCardType.Inputtable(
|
||||
onAmountChanged = {},
|
||||
onFocusChanged = {},
|
||||
inputError = TransactionCardType.InputError.Empty,
|
||||
accountTitleUM = AccountTitleUM.Account(
|
||||
prefixText = stringReference("From"),
|
||||
name = AccountNameUM.DefaultMain.value,
|
||||
icon = CryptoPortfolioIconConverter.convert(CryptoPortfolioIcon.ofDefaultCustomAccount()),
|
||||
),
|
||||
),
|
||||
amountTextFieldValue = TextFieldValue(),
|
||||
amountEquivalent = stringReference("1 000 000"),
|
||||
currencyIconState = CurrencyIconState.Loading,
|
||||
tokenSymbol = stringReference("DAI"),
|
||||
balance = "123",
|
||||
isBalanceHidden = false,
|
||||
)
|
||||
|
||||
val receiveCard = SwapCardState.SwapCardData(
|
||||
type = TransactionCardType.ReadOnly(
|
||||
accountTitleUM = AccountTitleUM.Account(
|
||||
prefixText = stringReference("To"),
|
||||
name = AccountNameUM.DefaultMain.value,
|
||||
icon = CryptoPortfolioIconConverter.convert(CryptoPortfolioIcon.ofDefaultCustomAccount()),
|
||||
),
|
||||
),
|
||||
amountTextFieldValue = TextFieldValue(),
|
||||
amountEquivalent = stringReference("1 000 000"),
|
||||
currencyIconState = CurrencyIconState.Loading,
|
||||
tokenSymbol = stringReference("DAI"),
|
||||
balance = "33333",
|
||||
isBalanceHidden = false,
|
||||
)
|
||||
|
||||
val emptyReadOnlyCard = SwapCardState.Empty(
|
||||
type = TransactionCardType.ReadOnly(
|
||||
accountTitleUM = AccountTitleUM.Text(title = resourceReference(R.string.swapping_to_title)),
|
||||
),
|
||||
amountEquivalent = stringReference("$0.00"),
|
||||
amountTextFieldValue = null,
|
||||
)
|
||||
|
||||
val emptyInputtableCard = SwapCardState.Empty(
|
||||
type = TransactionCardType.Inputtable(
|
||||
onAmountChanged = {},
|
||||
onFocusChanged = {},
|
||||
inputError = TransactionCardType.InputError.Empty,
|
||||
accountTitleUM = AccountTitleUM.Text(title = resourceReference(R.string.swapping_from_title)),
|
||||
),
|
||||
amountEquivalent = stringReference("$0.00"),
|
||||
amountTextFieldValue = null,
|
||||
)
|
||||
|
||||
val loadingCard = SwapCardState.Loading(
|
||||
type = TransactionCardType.Inputtable(
|
||||
onAmountChanged = {},
|
||||
onFocusChanged = {},
|
||||
inputError = TransactionCardType.InputError.Empty,
|
||||
accountTitleUM = AccountTitleUM.Text(title = resourceReference(R.string.swapping_to_title)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,867 @@
|
|||
package com.tangem.feature.swap
|
||||
|
||||
import arrow.core.right
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.domain.account.models.AccountStatusList
|
||||
import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer
|
||||
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
|
||||
import com.tangem.domain.core.lce.Lce
|
||||
import com.tangem.domain.exchange.RampStateManager
|
||||
import com.tangem.domain.models.StatusSource
|
||||
import com.tangem.domain.models.TokensGroupType
|
||||
import com.tangem.domain.models.TokensSortType
|
||||
import com.tangem.domain.models.TotalFiatBalance
|
||||
import com.tangem.domain.models.account.*
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.models.quote.PriceChange
|
||||
import com.tangem.domain.models.tokenlist.TokenList
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason
|
||||
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
|
||||
import com.tangem.feature.swap.model.InitialCurrenciesResolver
|
||||
import com.tangem.features.swap.SwapComponent.Params.CurrencyPosition
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
import java.math.BigDecimal
|
||||
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
internal class DefaultInitialCurrenciesResolverTest {
|
||||
|
||||
private val getUserWalletUseCase = mockk<GetUserWalletUseCase>()
|
||||
private val singleAccountStatusListSupplier = mockk<SingleAccountStatusListSupplier>()
|
||||
private val rampStateManager = mockk<RampStateManager>()
|
||||
|
||||
private val userWalletId = UserWalletId("0011")
|
||||
private val userWallet = mockk<UserWallet> {
|
||||
every { walletId } returns userWalletId
|
||||
}
|
||||
|
||||
private val resolver = InitialCurrenciesResolver(
|
||||
getUserWalletUseCase = getUserWalletUseCase,
|
||||
singleAccountStatusListSupplier = singleAccountStatusListSupplier,
|
||||
rampStateManager = rampStateManager,
|
||||
)
|
||||
|
||||
private var uniqueIndex = 0
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
coEvery { getUserWalletUseCase(userWalletId) } returns userWallet.right()
|
||||
}
|
||||
|
||||
// region no initial currency
|
||||
|
||||
@Test
|
||||
fun `GIVEN available tokens with balance WHEN no initial currency THEN returns available token with max fiat balance`() =
|
||||
runTest {
|
||||
val currency1 = mockCryptoCurrency()
|
||||
val currency2 = mockCryptoCurrency()
|
||||
val currency3 = mockCryptoCurrency()
|
||||
|
||||
val status1 = createCurrencyStatus(currency1, fiatAmount = BigDecimal("100"))
|
||||
val status2 = createCurrencyStatus(currency2, fiatAmount = BigDecimal("300"))
|
||||
val status3 = createCurrencyStatus(currency3, fiatAmount = BigDecimal("500"))
|
||||
|
||||
val accountStatus = createCryptoPortfolioAccountStatus(listOf(status1, status2, status3))
|
||||
setupSupplier(listOf(accountStatus))
|
||||
|
||||
setupAvailability(linkedMapOf(currency1 to true, currency2 to true, currency3 to false))
|
||||
|
||||
val (from, to) = resolver.invoke(
|
||||
userWalletId,
|
||||
initialCryptoCurrency = null,
|
||||
swapCurrencyPosition = CurrencyPosition.ANY,
|
||||
isPaymentAccount = false,
|
||||
)
|
||||
|
||||
assertThat(from?.status).isSameInstanceAs(status2)
|
||||
assertThat(to).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN available tokens without balance WHEN no initial currency THEN returns first token from first account`() =
|
||||
runTest {
|
||||
val currency1 = mockCryptoCurrency()
|
||||
val currency2 = mockCryptoCurrency()
|
||||
|
||||
val status1 = createCurrencyStatus(currency1, fiatAmount = BigDecimal.ZERO)
|
||||
val status2 = createCurrencyStatus(currency2, fiatAmount = null)
|
||||
|
||||
val accountStatus = createCryptoPortfolioAccountStatus(listOf(status1, status2))
|
||||
setupSupplier(listOf(accountStatus))
|
||||
|
||||
setupAvailability(linkedMapOf(currency1 to true, currency2 to true))
|
||||
|
||||
val (from, to) = resolver.invoke(
|
||||
userWalletId,
|
||||
initialCryptoCurrency = null,
|
||||
swapCurrencyPosition = CurrencyPosition.ANY,
|
||||
isPaymentAccount = false,
|
||||
)
|
||||
|
||||
assertThat(from?.status).isSameInstanceAs(status1)
|
||||
assertThat(to).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN no available tokens with balance WHEN no initial currency THEN returns token with max fiat balance`() =
|
||||
runTest {
|
||||
val currency1 = mockCryptoCurrency()
|
||||
val currency2 = mockCryptoCurrency()
|
||||
|
||||
val status1 = createCurrencyStatus(currency1, fiatAmount = BigDecimal("100"))
|
||||
val status2 = createCurrencyStatus(currency2, fiatAmount = BigDecimal("200"))
|
||||
|
||||
val accountStatus = createCryptoPortfolioAccountStatus(listOf(status1, status2))
|
||||
setupSupplier(listOf(accountStatus))
|
||||
|
||||
setupAvailability(linkedMapOf(currency1 to false, currency2 to false))
|
||||
|
||||
val (from, to) = resolver.invoke(
|
||||
userWalletId,
|
||||
initialCryptoCurrency = null,
|
||||
swapCurrencyPosition = CurrencyPosition.ANY,
|
||||
isPaymentAccount = false,
|
||||
)
|
||||
|
||||
assertThat(from?.status).isSameInstanceAs(status2)
|
||||
assertThat(to).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN no available tokens without balance WHEN no initial currency THEN returns first token from first account`() =
|
||||
runTest {
|
||||
val currency1 = mockCryptoCurrency()
|
||||
val currency2 = mockCryptoCurrency()
|
||||
|
||||
val status1 = createCurrencyStatus(currency1, fiatAmount = BigDecimal.ZERO)
|
||||
val status2 = createCurrencyStatus(currency2, fiatAmount = null)
|
||||
|
||||
val accountStatus = createCryptoPortfolioAccountStatus(listOf(status1, status2))
|
||||
setupSupplier(listOf(accountStatus))
|
||||
|
||||
setupAvailability(linkedMapOf(currency1 to false, currency2 to false))
|
||||
|
||||
val (from, to) = resolver.invoke(
|
||||
userWalletId,
|
||||
initialCryptoCurrency = null,
|
||||
swapCurrencyPosition = CurrencyPosition.ANY,
|
||||
isPaymentAccount = false,
|
||||
)
|
||||
|
||||
assertThat(from?.status).isSameInstanceAs(status1)
|
||||
assertThat(to).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN empty accounts WHEN no initial currency THEN returns null pair`() = runTest {
|
||||
setupSupplier(emptyList())
|
||||
|
||||
val (from, to) = resolver.invoke(
|
||||
userWalletId,
|
||||
initialCryptoCurrency = null,
|
||||
swapCurrencyPosition = CurrencyPosition.ANY,
|
||||
isPaymentAccount = false,
|
||||
)
|
||||
|
||||
assertThat(from).isNull()
|
||||
assertThat(to).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN multiple accounts WHEN fallback to first THEN returns first token from first account`() =
|
||||
runTest {
|
||||
val currency1 = mockCryptoCurrency()
|
||||
val currency2 = mockCryptoCurrency()
|
||||
|
||||
val status1 = createCurrencyStatus(currency1, fiatAmount = null)
|
||||
val status2 = createCurrencyStatus(currency2, fiatAmount = null)
|
||||
|
||||
val account1Status = createCryptoPortfolioAccountStatus(listOf(status1))
|
||||
val account2Status = createCryptoPortfolioAccountStatus(listOf(status2))
|
||||
setupSupplier(listOf(account1Status, account2Status))
|
||||
|
||||
setupAvailability(linkedMapOf(currency1 to true))
|
||||
setupAvailability(linkedMapOf(currency2 to true))
|
||||
|
||||
val (from, to) = resolver.invoke(
|
||||
userWalletId,
|
||||
initialCryptoCurrency = null,
|
||||
swapCurrencyPosition = CurrencyPosition.ANY,
|
||||
isPaymentAccount = false,
|
||||
)
|
||||
|
||||
assertThat(from?.status).isSameInstanceAs(status1)
|
||||
assertThat(to).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN mixed availability and balance across accounts WHEN no initial currency THEN returns best available with balance`() =
|
||||
runTest {
|
||||
val currency1 = mockCryptoCurrency()
|
||||
val currency2 = mockCryptoCurrency()
|
||||
val currency3 = mockCryptoCurrency()
|
||||
|
||||
val status1 = createCurrencyStatus(currency1, fiatAmount = BigDecimal("50"))
|
||||
val status2 = createCurrencyStatus(currency2, fiatAmount = BigDecimal("200"))
|
||||
val status3 = createCurrencyStatus(currency3, fiatAmount = BigDecimal("100"))
|
||||
|
||||
val account1Status = createCryptoPortfolioAccountStatus(listOf(status1))
|
||||
val account2Status = createCryptoPortfolioAccountStatus(listOf(status2, status3))
|
||||
setupSupplier(listOf(account1Status, account2Status))
|
||||
|
||||
setupAvailability(linkedMapOf(currency1 to true))
|
||||
setupAvailability(linkedMapOf(currency2 to false, currency3 to true))
|
||||
|
||||
val (from, to) = resolver.invoke(
|
||||
userWalletId,
|
||||
initialCryptoCurrency = null,
|
||||
swapCurrencyPosition = CurrencyPosition.ANY,
|
||||
isPaymentAccount = false,
|
||||
)
|
||||
|
||||
assertThat(from?.status).isSameInstanceAs(status3)
|
||||
assertThat(to).isNull()
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region initial currency tests
|
||||
|
||||
@Test
|
||||
fun `GIVEN initial currency not found WHEN invoke THEN returns null pair`() = runTest {
|
||||
val initialCurrency = mockCryptoCurrency()
|
||||
val otherCurrency = mockCryptoCurrency()
|
||||
|
||||
val status = createCurrencyStatus(otherCurrency, fiatAmount = BigDecimal("100"))
|
||||
val accountStatus = createCryptoPortfolioAccountStatus(listOf(status))
|
||||
setupSupplier(listOf(accountStatus))
|
||||
setupAvailability(linkedMapOf(otherCurrency to true))
|
||||
|
||||
val (from, to) = resolver.invoke(
|
||||
userWalletId,
|
||||
initialCryptoCurrency = initialCurrency,
|
||||
swapCurrencyPosition = CurrencyPosition.ANY,
|
||||
isPaymentAccount = false,
|
||||
)
|
||||
|
||||
assertThat(from).isNull()
|
||||
assertThat(to).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN initial currency available with balance WHEN invoke THEN returns it as from`() = runTest {
|
||||
val sharedId = mockk<CryptoCurrency.ID>(relaxed = true)
|
||||
val initialCurrency = mockCryptoCurrency(id = sharedId)
|
||||
val accountCurrency = mockCryptoCurrency(id = sharedId)
|
||||
|
||||
val status = createCurrencyStatus(accountCurrency, fiatAmount = BigDecimal("100"))
|
||||
val accountStatus = createCryptoPortfolioAccountStatus(listOf(status))
|
||||
setupSupplier(listOf(accountStatus))
|
||||
setupAvailability(linkedMapOf(accountCurrency to true))
|
||||
|
||||
val (from, to) = resolver.invoke(
|
||||
userWalletId,
|
||||
initialCryptoCurrency = initialCurrency,
|
||||
swapCurrencyPosition = CurrencyPosition.ANY,
|
||||
isPaymentAccount = false,
|
||||
)
|
||||
|
||||
assertThat(from?.status).isSameInstanceAs(status)
|
||||
assertThat(to).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN initial currency available without balance WHEN invoke THEN returns it as to and best as from`() =
|
||||
runTest {
|
||||
val sharedId = mockk<CryptoCurrency.ID>(relaxed = true)
|
||||
val initialCurrency = mockCryptoCurrency(id = sharedId)
|
||||
val accountCurrency = mockCryptoCurrency(id = sharedId)
|
||||
val otherCurrency = mockCryptoCurrency()
|
||||
|
||||
val initialStatus = createCurrencyStatus(accountCurrency, fiatAmount = BigDecimal.ZERO)
|
||||
val otherStatus = createCurrencyStatus(otherCurrency, fiatAmount = BigDecimal("200"))
|
||||
val accountStatus = createCryptoPortfolioAccountStatus(listOf(initialStatus, otherStatus))
|
||||
setupSupplier(listOf(accountStatus))
|
||||
|
||||
setupAvailability(linkedMapOf(accountCurrency to true, otherCurrency to true))
|
||||
|
||||
val (from, to) = resolver.invoke(
|
||||
userWalletId,
|
||||
initialCryptoCurrency = initialCurrency,
|
||||
swapCurrencyPosition = CurrencyPosition.ANY,
|
||||
isPaymentAccount = false,
|
||||
)
|
||||
|
||||
assertThat(from?.status).isSameInstanceAs(otherStatus)
|
||||
assertThat(to?.status).isSameInstanceAs(initialStatus)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN initial currency available without balance and is only token WHEN invoke THEN returns it as to and from is null`() =
|
||||
runTest {
|
||||
val sharedId = mockk<CryptoCurrency.ID>(relaxed = true)
|
||||
val initialCurrency = mockCryptoCurrency(id = sharedId)
|
||||
val accountCurrency = mockCryptoCurrency(id = sharedId)
|
||||
|
||||
val status = createCurrencyStatus(accountCurrency, fiatAmount = BigDecimal.ZERO)
|
||||
val accountStatus = createCryptoPortfolioAccountStatus(listOf(status))
|
||||
setupSupplier(listOf(accountStatus))
|
||||
setupAvailability(linkedMapOf(accountCurrency to true))
|
||||
|
||||
val (from, to) = resolver.invoke(
|
||||
userWalletId,
|
||||
initialCryptoCurrency = initialCurrency,
|
||||
swapCurrencyPosition = CurrencyPosition.ANY,
|
||||
isPaymentAccount = false,
|
||||
)
|
||||
|
||||
assertThat(from).isNull()
|
||||
assertThat(to?.status).isSameInstanceAs(status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN initial currency not available with balance WHEN invoke THEN returns it as from`() = runTest {
|
||||
val sharedId = mockk<CryptoCurrency.ID>(relaxed = true)
|
||||
val initialCurrency = mockCryptoCurrency(id = sharedId)
|
||||
val accountCurrency = mockCryptoCurrency(id = sharedId)
|
||||
|
||||
val status = createCurrencyStatus(accountCurrency, fiatAmount = BigDecimal("100"))
|
||||
val accountStatus = createCryptoPortfolioAccountStatus(listOf(status))
|
||||
setupSupplier(listOf(accountStatus))
|
||||
setupAvailability(linkedMapOf(accountCurrency to false))
|
||||
|
||||
val (from, to) = resolver.invoke(
|
||||
userWalletId,
|
||||
initialCryptoCurrency = initialCurrency,
|
||||
swapCurrencyPosition = CurrencyPosition.ANY,
|
||||
isPaymentAccount = false,
|
||||
)
|
||||
|
||||
assertThat(from?.status).isSameInstanceAs(status)
|
||||
assertThat(to).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN initial currency not available with balance and other available with higher balance WHEN invoke THEN returns initial as from`() =
|
||||
runTest {
|
||||
val sharedId = mockk<CryptoCurrency.ID>(relaxed = true)
|
||||
val initialCurrency = mockCryptoCurrency(id = sharedId)
|
||||
val accountCurrency = mockCryptoCurrency(id = sharedId)
|
||||
val otherCurrency = mockCryptoCurrency()
|
||||
|
||||
val initialStatus = createCurrencyStatus(accountCurrency, fiatAmount = BigDecimal("100"))
|
||||
val otherStatus = createCurrencyStatus(otherCurrency, fiatAmount = BigDecimal("500"))
|
||||
val accountStatus = createCryptoPortfolioAccountStatus(listOf(initialStatus, otherStatus))
|
||||
setupSupplier(listOf(accountStatus))
|
||||
|
||||
setupAvailability(linkedMapOf(accountCurrency to false, otherCurrency to true))
|
||||
|
||||
val (from, to) = resolver.invoke(
|
||||
userWalletId,
|
||||
initialCryptoCurrency = initialCurrency,
|
||||
swapCurrencyPosition = CurrencyPosition.ANY,
|
||||
isPaymentAccount = false,
|
||||
)
|
||||
|
||||
assertThat(from?.status).isSameInstanceAs(initialStatus)
|
||||
assertThat(to).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN initial currency not available without balance and other available with balance WHEN invoke THEN returns best available as from`() =
|
||||
runTest {
|
||||
val sharedId = mockk<CryptoCurrency.ID>(relaxed = true)
|
||||
val initialCurrency = mockCryptoCurrency(id = sharedId)
|
||||
val accountCurrency = mockCryptoCurrency(id = sharedId)
|
||||
val other1 = mockCryptoCurrency()
|
||||
val other2 = mockCryptoCurrency()
|
||||
|
||||
val initialStatus = createCurrencyStatus(accountCurrency, fiatAmount = null)
|
||||
val other1Status = createCurrencyStatus(other1, fiatAmount = BigDecimal("100"))
|
||||
val other2Status = createCurrencyStatus(other2, fiatAmount = BigDecimal("300"))
|
||||
val accountStatus = createCryptoPortfolioAccountStatus(
|
||||
listOf(initialStatus, other1Status, other2Status),
|
||||
)
|
||||
setupSupplier(listOf(accountStatus))
|
||||
|
||||
setupAvailability(linkedMapOf(accountCurrency to false, other1 to true, other2 to true))
|
||||
|
||||
val (from, to) = resolver.invoke(
|
||||
userWalletId,
|
||||
initialCryptoCurrency = initialCurrency,
|
||||
swapCurrencyPosition = CurrencyPosition.ANY,
|
||||
isPaymentAccount = false,
|
||||
)
|
||||
|
||||
assertThat(from?.status).isSameInstanceAs(other2Status)
|
||||
assertThat(to?.status).isSameInstanceAs(initialStatus)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN initial currency not available without balance and other available without balance WHEN invoke THEN returns first token as from`() =
|
||||
runTest {
|
||||
val sharedId = mockk<CryptoCurrency.ID>(relaxed = true)
|
||||
val initialCurrency = mockCryptoCurrency(id = sharedId)
|
||||
val accountCurrency = mockCryptoCurrency(id = sharedId)
|
||||
val otherCurrency = mockCryptoCurrency()
|
||||
|
||||
val initialStatus = createCurrencyStatus(accountCurrency, fiatAmount = null)
|
||||
val otherStatus = createCurrencyStatus(otherCurrency, fiatAmount = BigDecimal.ZERO)
|
||||
val accountStatus = createCryptoPortfolioAccountStatus(listOf(otherStatus, initialStatus))
|
||||
setupSupplier(listOf(accountStatus))
|
||||
|
||||
setupAvailability(linkedMapOf(otherCurrency to true, accountCurrency to false))
|
||||
|
||||
val (from, to) = resolver.invoke(
|
||||
userWalletId,
|
||||
initialCryptoCurrency = initialCurrency,
|
||||
swapCurrencyPosition = CurrencyPosition.ANY,
|
||||
isPaymentAccount = false,
|
||||
)
|
||||
|
||||
assertThat(from?.status).isSameInstanceAs(otherStatus)
|
||||
assertThat(to?.status).isSameInstanceAs(initialStatus)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN initial currency not available without balance and no available tokens with balance WHEN invoke THEN returns best by balance as from`() =
|
||||
runTest {
|
||||
val sharedId = mockk<CryptoCurrency.ID>(relaxed = true)
|
||||
val initialCurrency = mockCryptoCurrency(id = sharedId)
|
||||
val accountCurrency = mockCryptoCurrency(id = sharedId)
|
||||
val otherCurrency = mockCryptoCurrency()
|
||||
|
||||
val initialStatus = createCurrencyStatus(accountCurrency, fiatAmount = null)
|
||||
val otherStatus = createCurrencyStatus(otherCurrency, fiatAmount = BigDecimal("200"))
|
||||
val accountStatus = createCryptoPortfolioAccountStatus(listOf(initialStatus, otherStatus))
|
||||
setupSupplier(listOf(accountStatus))
|
||||
|
||||
setupAvailability(linkedMapOf(accountCurrency to false, otherCurrency to false))
|
||||
|
||||
val (from, to) = resolver.invoke(
|
||||
userWalletId,
|
||||
initialCryptoCurrency = initialCurrency,
|
||||
swapCurrencyPosition = CurrencyPosition.ANY,
|
||||
isPaymentAccount = false,
|
||||
)
|
||||
|
||||
assertThat(from?.status).isSameInstanceAs(otherStatus)
|
||||
assertThat(to?.status).isSameInstanceAs(initialStatus)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN initial currency not available without balance and no available tokens without balance WHEN invoke THEN returns first token as from`() =
|
||||
runTest {
|
||||
val sharedId = mockk<CryptoCurrency.ID>(relaxed = true)
|
||||
val initialCurrency = mockCryptoCurrency(id = sharedId)
|
||||
val accountCurrency = mockCryptoCurrency(id = sharedId)
|
||||
val otherCurrency = mockCryptoCurrency()
|
||||
|
||||
val initialStatus = createCurrencyStatus(accountCurrency, fiatAmount = null)
|
||||
val otherStatus = createCurrencyStatus(otherCurrency, fiatAmount = BigDecimal.ZERO)
|
||||
val accountStatus = createCryptoPortfolioAccountStatus(listOf(otherStatus, initialStatus))
|
||||
setupSupplier(listOf(accountStatus))
|
||||
|
||||
setupAvailability(linkedMapOf(otherCurrency to false, accountCurrency to false))
|
||||
|
||||
val (from, to) = resolver.invoke(
|
||||
userWalletId,
|
||||
initialCryptoCurrency = initialCurrency,
|
||||
swapCurrencyPosition = CurrencyPosition.ANY,
|
||||
isPaymentAccount = false,
|
||||
)
|
||||
|
||||
assertThat(from?.status).isSameInstanceAs(otherStatus)
|
||||
assertThat(to?.status).isSameInstanceAs(initialStatus)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN initial currency not available without balance and is only token WHEN invoke THEN returns it as to and from is null`() =
|
||||
runTest {
|
||||
val sharedId = mockk<CryptoCurrency.ID>(relaxed = true)
|
||||
val initialCurrency = mockCryptoCurrency(id = sharedId)
|
||||
val accountCurrency = mockCryptoCurrency(id = sharedId)
|
||||
|
||||
val status = createCurrencyStatus(accountCurrency, fiatAmount = null)
|
||||
val accountStatus = createCryptoPortfolioAccountStatus(listOf(status))
|
||||
setupSupplier(listOf(accountStatus))
|
||||
setupAvailability(linkedMapOf(accountCurrency to false))
|
||||
|
||||
val (from, to) = resolver.invoke(
|
||||
userWalletId,
|
||||
initialCryptoCurrency = initialCurrency,
|
||||
swapCurrencyPosition = CurrencyPosition.ANY,
|
||||
isPaymentAccount = false,
|
||||
)
|
||||
|
||||
assertThat(from).isNull()
|
||||
assertThat(to?.status).isSameInstanceAs(status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN initial from second account without balance and same token in main with balance WHEN invoke THEN does not pick same token as from`() =
|
||||
runTest {
|
||||
val sharedNetworkId = "ethereum"
|
||||
val sharedContractAddress = "0xUSDT"
|
||||
|
||||
// Same token (same network + contract), different ids (simulates different derivations)
|
||||
val idInSecondary = mockCurrencyId(sharedNetworkId, sharedContractAddress)
|
||||
val idInMain = mockCurrencyId(sharedNetworkId, sharedContractAddress)
|
||||
val initialCurrency = mockCryptoCurrency(id = idInSecondary)
|
||||
val usdtInSecondary = mockCryptoCurrency(id = idInSecondary)
|
||||
val usdtInMain = mockCryptoCurrency(id = idInMain)
|
||||
|
||||
val statusInSecondary = createCurrencyStatus(usdtInSecondary, fiatAmount = null)
|
||||
val statusInMain = createCurrencyStatus(usdtInMain, fiatAmount = BigDecimal("1000"))
|
||||
|
||||
val accountStatus = createCryptoPortfolioAccountStatus(listOf(statusInMain, statusInSecondary))
|
||||
setupSupplier(listOf(accountStatus))
|
||||
|
||||
setupAvailability(linkedMapOf(usdtInMain to true, usdtInSecondary to true))
|
||||
|
||||
val (from, to) = resolver.invoke(
|
||||
userWalletId,
|
||||
initialCryptoCurrency = initialCurrency,
|
||||
swapCurrencyPosition = CurrencyPosition.ANY,
|
||||
isPaymentAccount = false,
|
||||
)
|
||||
|
||||
// Selected goes to TO; FROM must not be the same token from another account
|
||||
assertThat(from).isNull()
|
||||
assertThat(to?.status).isSameInstanceAs(statusInSecondary)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN initial from second account with balance WHEN invoke THEN returns it as from`() = runTest {
|
||||
val idInSecondary = mockCurrencyId("ethereum", "0xUSDT")
|
||||
val initialCurrency = mockCryptoCurrency(id = idInSecondary)
|
||||
val usdtInSecondary = mockCryptoCurrency(id = idInSecondary)
|
||||
val otherCurrency = mockCryptoCurrency()
|
||||
|
||||
val statusInSecondary = createCurrencyStatus(usdtInSecondary, fiatAmount = BigDecimal("200"))
|
||||
val otherStatus = createCurrencyStatus(otherCurrency, fiatAmount = BigDecimal("500"))
|
||||
|
||||
val accountStatus = createCryptoPortfolioAccountStatus(listOf(otherStatus, statusInSecondary))
|
||||
setupSupplier(listOf(accountStatus))
|
||||
|
||||
setupAvailability(linkedMapOf(otherCurrency to true, usdtInSecondary to true))
|
||||
|
||||
val (from, to) = resolver.invoke(
|
||||
userWalletId,
|
||||
initialCryptoCurrency = initialCurrency,
|
||||
swapCurrencyPosition = CurrencyPosition.ANY,
|
||||
isPaymentAccount = false,
|
||||
)
|
||||
|
||||
assertThat(from?.status).isSameInstanceAs(statusInSecondary)
|
||||
assertThat(to).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN initial in secondary account placed in TO WHEN invoke THEN FROM is picked only from same account`() =
|
||||
runTest {
|
||||
// Main account has a high-balance available currency.
|
||||
val mainOnlyCurrency = mockCryptoCurrency()
|
||||
val mainStatus = createCurrencyStatus(mainOnlyCurrency, fiatAmount = BigDecimal("10000"))
|
||||
val mainAccount = createCryptoPortfolioAccountStatus(
|
||||
currencies = listOf(mainStatus),
|
||||
derivationIndexValue = 0,
|
||||
)
|
||||
|
||||
// Secondary account holds the initial currency (available, zero balance → TO)
|
||||
// plus another available currency with balance.
|
||||
val initialId = mockCurrencyId("ethereum", "0xUSDT")
|
||||
val initialCurrency = mockCryptoCurrency(id = initialId)
|
||||
val initialInSecondary = mockCryptoCurrency(id = initialId)
|
||||
val secondaryCompanion = mockCryptoCurrency()
|
||||
|
||||
val initialStatus = createCurrencyStatus(initialInSecondary, fiatAmount = BigDecimal.ZERO)
|
||||
val secondaryStatus = createCurrencyStatus(secondaryCompanion, fiatAmount = BigDecimal("50"))
|
||||
|
||||
val secondaryAccount = createCryptoPortfolioAccountStatus(
|
||||
currencies = listOf(initialStatus, secondaryStatus),
|
||||
derivationIndexValue = 1,
|
||||
)
|
||||
|
||||
setupSupplier(listOf(mainAccount, secondaryAccount))
|
||||
|
||||
setupAvailability(linkedMapOf(mainOnlyCurrency to true))
|
||||
setupAvailability(linkedMapOf(initialInSecondary to true, secondaryCompanion to true))
|
||||
|
||||
val (from, to) = resolver.invoke(
|
||||
userWalletId,
|
||||
initialCryptoCurrency = initialCurrency,
|
||||
swapCurrencyPosition = CurrencyPosition.ANY,
|
||||
isPaymentAccount = false,
|
||||
)
|
||||
|
||||
// FROM must come from secondary account only — never the main account's high-balance currency.
|
||||
assertThat(from?.status).isSameInstanceAs(secondaryStatus)
|
||||
assertThat(to?.status).isSameInstanceAs(initialStatus)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN initial in secondary account is only token in that account WHEN invoke THEN FROM is null`() =
|
||||
runTest {
|
||||
// Main account has candidates that must NOT be picked as FROM.
|
||||
val mainOnlyCurrency = mockCryptoCurrency()
|
||||
val mainStatus = createCurrencyStatus(mainOnlyCurrency, fiatAmount = BigDecimal("500"))
|
||||
val mainAccount = createCryptoPortfolioAccountStatus(
|
||||
currencies = listOf(mainStatus),
|
||||
derivationIndexValue = 0,
|
||||
)
|
||||
|
||||
// Secondary account has only the initial currency (available, no balance → TO).
|
||||
val initialId = mockCurrencyId("ethereum", "0xUSDT")
|
||||
val initialCurrency = mockCryptoCurrency(id = initialId)
|
||||
val initialInSecondary = mockCryptoCurrency(id = initialId)
|
||||
|
||||
val initialStatus = createCurrencyStatus(initialInSecondary, fiatAmount = BigDecimal.ZERO)
|
||||
val secondaryAccount = createCryptoPortfolioAccountStatus(
|
||||
currencies = listOf(initialStatus),
|
||||
derivationIndexValue = 1,
|
||||
)
|
||||
|
||||
setupSupplier(listOf(mainAccount, secondaryAccount))
|
||||
|
||||
setupAvailability(linkedMapOf(mainOnlyCurrency to true))
|
||||
setupAvailability(linkedMapOf(initialInSecondary to true))
|
||||
|
||||
val (from, to) = resolver.invoke(
|
||||
userWalletId,
|
||||
initialCryptoCurrency = initialCurrency,
|
||||
swapCurrencyPosition = CurrencyPosition.ANY,
|
||||
isPaymentAccount = false,
|
||||
)
|
||||
|
||||
// Secondary account has no other candidates; FROM must be null, not pulled from main.
|
||||
assertThat(from).isNull()
|
||||
assertThat(to?.status).isSameInstanceAs(initialStatus)
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region currency position FROM
|
||||
|
||||
@Test
|
||||
fun `GIVEN position FROM and available with balance WHEN invoke THEN returns selected as from`() = runTest {
|
||||
val sharedId = mockk<CryptoCurrency.ID>(relaxed = true)
|
||||
val initialCurrency = mockCryptoCurrency(id = sharedId)
|
||||
val accountCurrency = mockCryptoCurrency(id = sharedId)
|
||||
|
||||
val status = createCurrencyStatus(accountCurrency, fiatAmount = BigDecimal("100"))
|
||||
val accountStatus = createCryptoPortfolioAccountStatus(listOf(status))
|
||||
setupSupplier(listOf(accountStatus))
|
||||
setupAvailability(linkedMapOf(accountCurrency to true))
|
||||
|
||||
val (from, to) = resolver.invoke(
|
||||
userWalletId,
|
||||
initialCryptoCurrency = initialCurrency,
|
||||
swapCurrencyPosition = CurrencyPosition.FROM,
|
||||
isPaymentAccount = false,
|
||||
)
|
||||
|
||||
assertThat(from?.status).isSameInstanceAs(status)
|
||||
assertThat(to).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN position FROM and not available without balance WHEN invoke THEN still returns selected as from`() =
|
||||
runTest {
|
||||
val sharedId = mockk<CryptoCurrency.ID>(relaxed = true)
|
||||
val initialCurrency = mockCryptoCurrency(id = sharedId)
|
||||
val accountCurrency = mockCryptoCurrency(id = sharedId)
|
||||
|
||||
val status = createCurrencyStatus(accountCurrency, fiatAmount = null)
|
||||
val accountStatus = createCryptoPortfolioAccountStatus(listOf(status))
|
||||
setupSupplier(listOf(accountStatus))
|
||||
setupAvailability(linkedMapOf(accountCurrency to false))
|
||||
|
||||
val (from, to) = resolver.invoke(
|
||||
userWalletId,
|
||||
initialCryptoCurrency = initialCurrency,
|
||||
swapCurrencyPosition = CurrencyPosition.FROM,
|
||||
isPaymentAccount = false,
|
||||
)
|
||||
|
||||
assertThat(from?.status).isSameInstanceAs(status)
|
||||
assertThat(to).isNull()
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region currency position TO
|
||||
|
||||
@Test
|
||||
fun `GIVEN position TO and available with balance WHEN invoke THEN returns selected as to`() = runTest {
|
||||
val sharedId = mockk<CryptoCurrency.ID>(relaxed = true)
|
||||
val initialCurrency = mockCryptoCurrency(id = sharedId)
|
||||
val accountCurrency = mockCryptoCurrency(id = sharedId)
|
||||
|
||||
val status = createCurrencyStatus(accountCurrency, fiatAmount = BigDecimal("100"))
|
||||
val accountStatus = createCryptoPortfolioAccountStatus(listOf(status))
|
||||
setupSupplier(listOf(accountStatus))
|
||||
setupAvailability(linkedMapOf(accountCurrency to true))
|
||||
|
||||
val (from, to) = resolver.invoke(
|
||||
userWalletId,
|
||||
initialCryptoCurrency = initialCurrency,
|
||||
swapCurrencyPosition = CurrencyPosition.TO,
|
||||
isPaymentAccount = false,
|
||||
)
|
||||
|
||||
assertThat(from).isNull()
|
||||
assertThat(to?.status).isSameInstanceAs(status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN position TO and not available without balance WHEN invoke THEN still returns selected as to`() =
|
||||
runTest {
|
||||
val sharedId = mockk<CryptoCurrency.ID>(relaxed = true)
|
||||
val initialCurrency = mockCryptoCurrency(id = sharedId)
|
||||
val accountCurrency = mockCryptoCurrency(id = sharedId)
|
||||
|
||||
val status = createCurrencyStatus(accountCurrency, fiatAmount = null)
|
||||
val accountStatus = createCryptoPortfolioAccountStatus(listOf(status))
|
||||
setupSupplier(listOf(accountStatus))
|
||||
setupAvailability(linkedMapOf(accountCurrency to false))
|
||||
|
||||
val (from, to) = resolver.invoke(
|
||||
userWalletId,
|
||||
initialCryptoCurrency = initialCurrency,
|
||||
swapCurrencyPosition = CurrencyPosition.TO,
|
||||
isPaymentAccount = false,
|
||||
)
|
||||
|
||||
assertThat(from).isNull()
|
||||
assertThat(to?.status).isSameInstanceAs(status)
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region helpers
|
||||
|
||||
private fun mockCryptoCurrency(
|
||||
id: CryptoCurrency.ID = mockCurrencyId(),
|
||||
): CryptoCurrency = mockk(relaxed = true) {
|
||||
every { this@mockk.id } returns id
|
||||
}
|
||||
|
||||
private fun mockCurrencyId(
|
||||
rawNetworkId: String = "net-${uniqueIndex++}",
|
||||
contractAddress: String = "contract-${uniqueIndex++}",
|
||||
): CryptoCurrency.ID = mockk(relaxed = true) {
|
||||
every { this@mockk.rawNetworkId } returns rawNetworkId
|
||||
every { this@mockk.contractAddress } returns contractAddress
|
||||
}
|
||||
|
||||
private fun createCurrencyStatus(
|
||||
currency: CryptoCurrency,
|
||||
fiatAmount: BigDecimal?,
|
||||
): CryptoCurrencyStatus {
|
||||
val value = mockk<CryptoCurrencyStatus.Value> {
|
||||
every { this@mockk.fiatAmount } returns fiatAmount
|
||||
}
|
||||
return CryptoCurrencyStatus(currency = currency, value = value)
|
||||
}
|
||||
|
||||
private fun createCryptoPortfolioAccountStatus(
|
||||
currencies: List<CryptoCurrencyStatus>,
|
||||
): AccountStatus.CryptoPortfolio {
|
||||
val account = Account.CryptoPortfolio.createMainAccount(userWalletId = userWalletId)
|
||||
return AccountStatus.CryptoPortfolio(
|
||||
account = account,
|
||||
tokenList = TokenList.Ungrouped(
|
||||
totalFiatBalance = TotalFiatBalance.Loaded(
|
||||
amount = BigDecimal.ZERO,
|
||||
source = StatusSource.ACTUAL,
|
||||
),
|
||||
sortedBy = TokensSortType.NONE,
|
||||
currencies = currencies,
|
||||
),
|
||||
priceChangeLce = Lce.Content(PriceChange(value = BigDecimal.ZERO, source = StatusSource.ACTUAL)),
|
||||
)
|
||||
}
|
||||
|
||||
private fun createCryptoPortfolioAccountStatus(
|
||||
currencies: List<CryptoCurrencyStatus>,
|
||||
derivationIndexValue: Int,
|
||||
): AccountStatus.CryptoPortfolio {
|
||||
val derivationIndex = requireNotNull(DerivationIndex(value = derivationIndexValue).getOrNull()) {
|
||||
"Invalid derivation index for test: $derivationIndexValue"
|
||||
}
|
||||
val accountId = AccountId.forCryptoPortfolio(
|
||||
userWalletId = userWalletId,
|
||||
derivationIndex = derivationIndex,
|
||||
)
|
||||
val accountName = if (derivationIndex.isMain) {
|
||||
AccountName.DefaultMain
|
||||
} else {
|
||||
requireNotNull(AccountName.Custom(value = "Account $derivationIndexValue").getOrNull()) {
|
||||
"Invalid account name for test"
|
||||
}
|
||||
}
|
||||
val account = Account.CryptoPortfolio(
|
||||
accountId = accountId,
|
||||
accountName = accountName,
|
||||
icon = CryptoPortfolioIcon.ofMainAccount(userWalletId),
|
||||
derivationIndex = derivationIndex,
|
||||
)
|
||||
return AccountStatus.CryptoPortfolio(
|
||||
account = account,
|
||||
tokenList = TokenList.Ungrouped(
|
||||
totalFiatBalance = TotalFiatBalance.Loaded(
|
||||
amount = BigDecimal.ZERO,
|
||||
source = StatusSource.ACTUAL,
|
||||
),
|
||||
sortedBy = TokensSortType.NONE,
|
||||
currencies = currencies,
|
||||
),
|
||||
priceChangeLce = Lce.Content(PriceChange(value = BigDecimal.ZERO, source = StatusSource.ACTUAL)),
|
||||
)
|
||||
}
|
||||
|
||||
private fun setupSupplier(accountStatuses: List<AccountStatus>) {
|
||||
val accountStatusList = if (accountStatuses.isEmpty()) {
|
||||
null
|
||||
} else {
|
||||
AccountStatusList(
|
||||
userWalletId = userWalletId,
|
||||
accountStatuses = accountStatuses,
|
||||
totalAccounts = accountStatuses.size,
|
||||
totalArchivedAccounts = 0,
|
||||
totalFiatBalance = TotalFiatBalance.Loaded(
|
||||
amount = BigDecimal.ZERO,
|
||||
source = StatusSource.ACTUAL,
|
||||
),
|
||||
sortType = TokensSortType.NONE,
|
||||
groupType = TokensGroupType.NONE,
|
||||
)
|
||||
}
|
||||
coEvery {
|
||||
singleAccountStatusListSupplier.getSyncOrNull(any<SingleAccountStatusListProducer.Params>(), any())
|
||||
} returns accountStatusList
|
||||
}
|
||||
|
||||
private fun setupAvailability(currenciesAvailability: LinkedHashMap<CryptoCurrency, Boolean>) {
|
||||
val result = currenciesAvailability.map { (currency, available) ->
|
||||
val reason = if (available) {
|
||||
ScenarioUnavailabilityReason.None
|
||||
} else {
|
||||
ScenarioUnavailabilityReason.Unreachable
|
||||
}
|
||||
currency to reason
|
||||
}.toMap()
|
||||
coEvery { rampStateManager.availableForSwap(userWalletId, currenciesAvailability.keys.toList()) } returns result
|
||||
}
|
||||
|
||||
// endregion
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue